refactor: app orchestration into tui

This commit is contained in:
2026-04-29 20:35:20 +02:00
parent 3d012e0c2e
commit 1147f4d25b
11 changed files with 1149 additions and 1086 deletions
+48
View File
@@ -0,0 +1,48 @@
package tui
import (
"parasocial/internal/auth"
"parasocial/internal/twitch"
)
// IRCState describes the current IRC join lifecycle for one streamer row.
type IRCState string
const (
IRCPending IRCState = "pending"
IRCJoined IRCState = "joined"
IRCDisconnected IRCState = "disconnected"
)
// AuthUpdate carries one incremental auth log line or completion result into the TUI.
type AuthUpdate struct {
Line string
State *auth.State
Err error
Done bool
}
// StreamerUpdate carries one streamer resolution update into the TUI.
type StreamerUpdate struct {
Viewer *twitch.Viewer
Entry *twitch.StreamerEntry
IRC *IRCUpdate
Index int
Err error
Done bool
}
// IRCUpdate carries one IRC connection state or log line into the TUI.
type IRCUpdate struct {
Login string
State IRCState
Line string
}
type authStartedMsg struct {
Updates <-chan AuthUpdate
}
type streamerStartedMsg struct {
Updates <-chan StreamerUpdate
}
+126 -378
View File
@@ -1,13 +1,10 @@
// model.go contains the Bubble Tea state machine for the current terminal UI.
// It switches between the Twitch login log view and the streamer list view,
// and applies auth progress messages emitted by the app layer.
package tui
import (
"errors"
"fmt"
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"parasocial/internal/auth"
@@ -15,7 +12,6 @@ import (
)
const defaultViewWidth = 80
const greenDot = "\x1b[32m●\x1b[0m"
type viewMode int
@@ -24,70 +20,16 @@ const (
streamerView
)
// IRCState describes the current IRC join lifecycle for one streamer row.
type IRCState string
const (
IRCPending IRCState = "pending"
IRCJoined IRCState = "joined"
IRCDisconnected IRCState = "disconnected"
)
// StartAuthFunc begins the asynchronous Twitch login flow for the model.
type StartAuthFunc func(chan<- AuthUpdate)
// StartResolveFunc begins the asynchronous viewer and streamer resolution flow for the model.
type StartResolveFunc func(*auth.State, chan<- StreamerUpdate)
// AuthUpdate carries one incremental auth log line or completion result into the TUI.
type AuthUpdate struct {
Line string
State *auth.State
Err error
Done bool
}
// StreamerUpdate carries one streamer resolution update into the TUI.
type StreamerUpdate struct {
Viewer *twitch.Viewer
Entry *twitch.StreamerEntry
IRC *IRCUpdate
Index int
Err error
Done bool
}
// IRCUpdate carries one IRC connection state or log line into the TUI.
type IRCUpdate struct {
Login string
State IRCState
Line string
}
// Options configures the initial streamer list, auth state, and worker starter hooks.
type Options struct {
Streamers []twitch.StreamerEntry
AuthState *auth.State
StartAuth StartAuthFunc
StartResolve StartResolveFunc
}
// authStartedMsg hands the model the channel that will stream auth updates.
type authStartedMsg struct {
Updates <-chan AuthUpdate
}
// streamerStartedMsg hands the model the channel that will stream streamer updates.
type streamerStartedMsg struct {
Updates <-chan StreamerUpdate
type modelRuntime interface {
startAuth(chan<- AuthUpdate)
startResolve(*auth.State, chan<- StreamerUpdate)
}
// Model is the terminal UI for auth and streamer display.
type Model struct {
streamers []twitch.StreamerEntry
authState *auth.State
startAuth StartAuthFunc
startResolve StartResolveFunc
runtime modelRuntime
authUpdates <-chan AuthUpdate
streamerUpdates <-chan StreamerUpdate
authLogs []string
@@ -99,44 +41,49 @@ type Model struct {
height int
selectedConfig string
ircDetails map[string]ircDetail
authViewport viewport.Model
}
type ircDetail struct {
joined bool
}
type streamerRow struct {
index int
entry twitch.StreamerEntry
line string
}
// New returns a Bubble Tea model for auth and streamer display.
func New(options Options) Model {
streamers := twitch.LoadingStreamerEntries(options.Streamers)
if len(options.initialStreamers) > 0 {
streamers = append([]twitch.StreamerEntry(nil), options.initialStreamers...)
}
model := Model{
streamers: append([]twitch.StreamerEntry(nil), options.Streamers...),
streamers: streamers,
authState: options.AuthState,
startAuth: options.StartAuth,
startResolve: options.StartResolve,
runtime: options.runtime,
authLogs: []string{},
mode: authView,
ircDetails: make(map[string]ircDetail),
width: defaultViewWidth,
height: 24,
authViewport: newAuthViewport(contentWidth(defaultViewWidth), authViewportHeight(24)),
}
if options.AuthState != nil {
model.mode = streamerView
}
model.ensureSelection()
model.syncAuthViewport()
return model
}
// Init kicks off authentication only when the UI starts in the login state.
func (m Model) Init() tea.Cmd {
if m.mode == authView {
return startAuthSession(m.startAuth)
return startAuthSession(m.runtime)
}
return startStreamerResolution(m.startResolve, m.authState)
return startStreamerResolution(m.runtime, m.authState)
}
// Update applies auth progress events and handles the global quit keys.
// Update applies runtime progress events and handles global keys.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case authStartedMsg:
@@ -146,158 +93,95 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.streamerUpdates = msg.Updates
return m, waitForStreamerUpdate(msg.Updates)
case AuthUpdate:
if msg.Line != "" {
m.authLogs = append(m.authLogs, msg.Line)
}
if msg.Done {
if msg.Err != nil {
m.authErr = msg.Err
return m, nil
}
if msg.State != nil {
m.authState = msg.State
m.authErr = nil
m.mode = streamerView
m.viewer = nil
m.resolveErr = nil
m.streamers = loadingEntries(m.streamers)
m.ircDetails = make(map[string]ircDetail)
m.selectedConfig = ""
m.ensureSelection()
return m, startStreamerResolution(m.startResolve, m.authState)
}
return m, nil
}
if m.authUpdates != nil {
return m, waitForAuthUpdate(m.authUpdates)
}
return m.updateAuth(msg)
case StreamerUpdate:
if msg.Viewer != nil {
m.viewer = msg.Viewer
m.resolveErr = nil
}
if msg.Entry != nil && msg.Index >= 0 && msg.Index < len(m.streamers) {
m.streamers[msg.Index] = *msg.Entry
}
if msg.IRC != nil {
m.applyIRCUpdate(*msg.IRC)
}
if msg.Err != nil {
m.resolveErr = msg.Err
}
m.ensureSelection()
if msg.Done {
return m, nil
}
if m.streamerUpdates != nil {
return m, waitForStreamerUpdate(m.streamerUpdates)
}
return m.updateStreamer(msg)
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.resizeComponents()
m.ensureSelection()
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "up", "k":
if m.mode == streamerView {
m.moveSelection(-1)
}
return m, nil
case "down", "j":
if m.mode == streamerView {
m.moveSelection(1)
}
return m, nil
case "ctrl+c", "esc", "q":
return m, tea.Quit
}
}
if m.mode == authView {
var cmd tea.Cmd
m.authViewport, cmd = m.authViewport.Update(msg)
return m, cmd
}
return m, nil
}
// View renders either the login log screen or the authenticated streamer list.
func (m Model) View() string {
if m.mode == authView {
var builder strings.Builder
builder.WriteString("Twitch Login\n")
if len(m.authLogs) == 0 {
builder.WriteString("Starting authentication...\n")
} else {
for _, line := range m.authLogs {
builder.WriteString(line)
builder.WriteByte('\n')
}
}
if m.authErr != nil {
builder.WriteString("\nLogin did not complete. Press q to quit.\n")
}
builder.WriteString("\n")
return builder.String()
func (m Model) updateAuth(msg AuthUpdate) (tea.Model, tea.Cmd) {
if msg.Line != "" {
m.authLogs = append(m.authLogs, msg.Line)
m.syncAuthViewport()
}
return m.renderStreamerView()
if msg.Done {
if msg.Err != nil {
m.authErr = msg.Err
m.syncAuthViewport()
return m, nil
}
if msg.State != nil {
m.authState = msg.State
m.authErr = nil
m.mode = streamerView
m.viewer = nil
m.resolveErr = nil
m.streamers = loadingEntries(m.streamers)
m.ircDetails = make(map[string]ircDetail)
m.selectedConfig = ""
m.ensureSelection()
m.resizeComponents()
return m, startStreamerResolution(m.runtime, m.authState)
}
return m, nil
}
if m.authUpdates != nil {
return m, waitForAuthUpdate(m.authUpdates)
}
return m, nil
}
func (m Model) renderStreamerView() string {
var builder strings.Builder
switch {
case m.viewer != nil:
fmt.Fprintf(&builder, "Logged in as %s (%s)\n", m.viewer.Login, m.viewer.ID)
case m.authState != nil && m.authState.Login != "":
fmt.Fprintf(&builder, "Logged in as %s\n", m.authState.Login)
if m.resolveErr != nil {
fmt.Fprintf(&builder, "Viewer lookup failed: %v\n", m.resolveErr)
} else {
builder.WriteString("Resolving viewer identity...\n")
}
func (m Model) updateStreamer(msg StreamerUpdate) (tea.Model, tea.Cmd) {
if msg.Viewer != nil {
m.viewer = msg.Viewer
m.resolveErr = nil
}
fmt.Fprintf(&builder, "Watching: %s\n", watchingSummary(m.streamers))
rows := m.orderedRows()
selectedIndex := m.selectedRowIndex(rows)
leftPane := m.leftPaneLines(rows)
rightPane := m.rightPaneLines(rows, selectedIndex)
if m.height > 0 {
available := m.height - 3
if available > 0 {
leftPane = clipListLines(leftPane, available, selectedIndex+1)
rightPane = clipTailLines(rightPane, available)
}
if msg.Entry != nil && msg.Index >= 0 && msg.Index < len(m.streamers) {
m.streamers[msg.Index] = *msg.Entry
}
bodyWidth := m.width
if bodyWidth <= 0 {
bodyWidth = defaultViewWidth
if msg.IRC != nil {
m.applyIRCUpdate(*msg.IRC)
}
builder.WriteString("\n")
for _, line := range renderColumns(leftPane, rightPane, bodyWidth) {
builder.WriteString(line)
builder.WriteByte('\n')
if msg.Err != nil {
m.resolveErr = msg.Err
}
builder.WriteByte('\n')
return builder.String()
}
func watchingSummary(streamers []twitch.StreamerEntry) string {
live := make([]string, 0, 2)
for _, streamer := range streamers {
if streamer.Status != twitch.StreamerReady || !streamer.Live {
continue
}
name := streamer.Login
if name == "" {
name = streamer.ConfigLogin
}
live = append(live, name)
if len(live) == 2 {
break
}
m.ensureSelection()
m.resizeComponents()
if msg.Done {
return m, nil
}
if len(live) == 0 {
return "no live streamers"
if m.streamerUpdates != nil {
return m, waitForStreamerUpdate(m.streamerUpdates)
}
return strings.Join(live, ", ")
return m, nil
}
func (m *Model) applyIRCUpdate(update IRCUpdate) {
@@ -314,114 +198,89 @@ func (m *Model) applyIRCUpdate(update IRCUpdate) {
detail.joined = false
}
if update.Line != "" {
detail.line = update.Line
}
m.ircDetails[login] = detail
}
func (m *Model) ensureSelection() {
rows := m.orderedRows()
if len(rows) == 0 {
entries := m.orderedStreamers()
if len(entries) == 0 {
m.selectedConfig = ""
return
}
if m.selectedConfig == "" {
m.selectedConfig = rows[0].entry.ConfigLogin
m.selectedConfig = entries[0].ConfigLogin
return
}
for _, row := range rows {
if row.entry.ConfigLogin == m.selectedConfig {
for _, entry := range entries {
if entry.ConfigLogin == m.selectedConfig {
return
}
}
m.selectedConfig = rows[0].entry.ConfigLogin
m.selectedConfig = entries[0].ConfigLogin
}
func (m *Model) moveSelection(delta int) {
rows := m.orderedRows()
if len(rows) == 0 {
entries := m.orderedStreamers()
if len(entries) == 0 {
m.selectedConfig = ""
return
}
m.ensureSelection()
selected := m.selectedRowIndex(rows)
selected := m.selectedRowIndex(entries) + delta
if selected < 0 {
selected = 0
}
selected += delta
if selected < 0 {
selected = 0
if selected >= len(entries) {
selected = len(entries) - 1
}
if selected >= len(rows) {
selected = len(rows) - 1
}
m.selectedConfig = rows[selected].entry.ConfigLogin
m.selectedConfig = entries[selected].ConfigLogin
}
func (m Model) orderedRows() []streamerRow {
rows := make([]streamerRow, 0, len(m.streamers))
for index, entry := range m.streamers {
func (m Model) orderedStreamers() []twitch.StreamerEntry {
entries := make([]twitch.StreamerEntry, 0, len(m.streamers))
for _, entry := range m.streamers {
if isActive(entry) {
rows = append(rows, streamerRow{index: index, entry: entry})
entries = append(entries, entry)
}
}
for index, entry := range m.streamers {
for _, entry := range m.streamers {
if isActive(entry) {
continue
}
rows = append(rows, streamerRow{index: index, entry: entry})
entries = append(entries, entry)
}
return rows
return entries
}
func (m Model) selectedRowIndex(rows []streamerRow) int {
for index, row := range rows {
if row.entry.ConfigLogin == m.selectedConfig {
func (m Model) selectedRowIndex(entries []twitch.StreamerEntry) int {
for index, entry := range entries {
if entry.ConfigLogin == m.selectedConfig {
return index
}
}
if len(rows) == 0 {
if len(entries) == 0 {
return -1
}
return 0
}
func (m Model) leftPaneLines(rows []streamerRow) []string {
lines := []string{"Streamers"}
for _, row := range rows {
prefix := " "
if row.entry.ConfigLogin == m.selectedConfig {
prefix = "> "
}
status := "inactive"
if isActive(row.entry) {
status = "active"
}
lines = append(lines, prefix+streamerName(row.entry)+" ["+status+"]")
}
if len(rows) == 0 {
lines = append(lines, " none")
}
return lines
func (m *Model) resizeComponents() {
m.authViewport.Width = contentWidth(m.width)
m.authViewport.Height = authViewportHeight(m.height)
}
func (m Model) rightPaneLines(rows []streamerRow, selected int) []string {
lines := []string{"IRC Chat"}
if selected < 0 || selected >= len(rows) {
return append(lines, "")
}
func (m *Model) syncAuthViewport() {
m.authViewport.SetContent(m.authLogContent())
m.authViewport.GotoBottom()
}
row := rows[selected].entry
if !isActive(row) {
return append(lines, "inactive")
func (m Model) authLogContent() string {
if len(m.authLogs) == 0 {
return "Starting authentication..."
}
detail := m.ircDetails[normalizeKey(row.Login)]
if !detail.joined {
return append(lines, "not joined")
}
return append(lines, greenDot)
return strings.Join(m.authLogs, "\n")
}
func isActive(entry twitch.StreamerEntry) bool {
@@ -439,140 +298,39 @@ func normalizeKey(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func renderColumns(left, right []string, totalWidth int) []string {
if totalWidth <= 0 {
totalWidth = defaultViewWidth
func loadingEntries(entries []twitch.StreamerEntry) []twitch.StreamerEntry {
logins := make([]string, 0, len(entries))
for _, entry := range entries {
logins = append(logins, entry.ConfigLogin)
}
leftWidth, rightWidth := splitWidths(totalWidth)
height := len(left)
if len(right) > height {
height = len(right)
}
lines := make([]string, 0, height)
for index := 0; index < height; index++ {
leftLine := ""
if index < len(left) {
leftLine = left[index]
}
rightLine := ""
if index < len(right) {
rightLine = right[index]
}
lines = append(lines, fitWidth(leftLine, leftWidth)+" │ "+fitWidth(rightLine, rightWidth))
}
return lines
return twitch.LoadingStreamerEntries(logins)
}
func splitWidths(totalWidth int) (int, int) {
if totalWidth < 8 {
return max(1, totalWidth-4), 1
}
leftWidth := totalWidth / 3
if leftWidth < 20 {
leftWidth = 20
}
if leftWidth > 32 {
leftWidth = 32
}
rightWidth := totalWidth - leftWidth - 3
if rightWidth < 8 {
rightWidth = 8
leftWidth = totalWidth - rightWidth - 3
if leftWidth < 1 {
leftWidth = 1
rightWidth = max(1, totalWidth-leftWidth-3)
}
}
return leftWidth, rightWidth
}
func fitWidth(value string, width int) string {
if width <= 0 {
return ""
}
runes := []rune(value)
if len(runes) > width {
return string(runes[:width])
}
return value + strings.Repeat(" ", width-len(runes))
}
func clipListLines(lines []string, height, selectedLine int) []string {
if height <= 0 || len(lines) <= height {
return lines
}
if height == 1 {
return lines[:1]
}
rows := lines[1:]
visibleRows := height - 1
selectedRow := selectedLine - 1
if selectedRow < 0 {
selectedRow = 0
}
start := 0
if selectedRow >= visibleRows {
start = selectedRow - visibleRows + 1
}
if start+visibleRows > len(rows) {
start = len(rows) - visibleRows
}
if start < 0 {
start = 0
}
return append([]string{lines[0]}, rows[start:start+visibleRows]...)
}
func clipTailLines(lines []string, height int) []string {
if height <= 0 || len(lines) <= height {
return lines
}
if height == 1 {
return lines[:1]
}
body := lines[1:]
visibleRows := height - 1
if len(body) > visibleRows {
body = body[len(body)-visibleRows:]
}
return append([]string{lines[0]}, body...)
}
// startAuthSession starts the background auth worker and returns its update channel to Bubble Tea.
func startAuthSession(start StartAuthFunc) tea.Cmd {
func startAuthSession(runtime modelRuntime) tea.Cmd {
return func() tea.Msg {
if start == nil {
return AuthUpdate{Err: errors.New("auth start function is nil"), Done: true}
if runtime == nil {
return AuthUpdate{Err: errors.New("auth runtime is nil"), Done: true}
}
updates := make(chan AuthUpdate, 32)
start(updates)
runtime.startAuth(updates)
return authStartedMsg{Updates: updates}
}
}
// startStreamerResolution starts the background streamer resolver and returns its update channel.
func startStreamerResolution(start StartResolveFunc, state *auth.State) tea.Cmd {
func startStreamerResolution(runtime modelRuntime, state *auth.State) tea.Cmd {
return func() tea.Msg {
if start == nil {
return StreamerUpdate{Err: errors.New("streamer resolution start function is nil"), Done: true}
if runtime == nil {
return StreamerUpdate{Err: errors.New("streamer runtime is nil"), Done: true}
}
if state == nil {
return StreamerUpdate{Err: errors.New("auth state is nil"), Done: true}
}
updates := make(chan StreamerUpdate, 32)
start(state, updates)
runtime.startResolve(state, updates)
return streamerStartedMsg{Updates: updates}
}
}
// waitForAuthUpdate blocks until the next auth update is available from the worker.
func waitForAuthUpdate(updates <-chan AuthUpdate) tea.Cmd {
return func() tea.Msg {
update, ok := <-updates
@@ -583,7 +341,6 @@ func waitForAuthUpdate(updates <-chan AuthUpdate) tea.Cmd {
}
}
// waitForStreamerUpdate blocks until the next streamer update is available from the worker.
func waitForStreamerUpdate(updates <-chan StreamerUpdate) tea.Cmd {
return func() tea.Msg {
update, ok := <-updates
@@ -593,12 +350,3 @@ func waitForStreamerUpdate(updates <-chan StreamerUpdate) tea.Cmd {
return update
}
}
// loadingEntries resets the UI rows back to their initial loading state.
func loadingEntries(entries []twitch.StreamerEntry) []twitch.StreamerEntry {
logins := make([]string, 0, len(entries))
for _, entry := range entries {
logins = append(logins, entry.ConfigLogin)
}
return twitch.LoadingStreamerEntries(logins)
}
+108 -158
View File
@@ -5,118 +5,94 @@ import (
"testing"
tea "github.com/charmbracelet/bubbletea"
"parasocial/internal/auth"
"parasocial/internal/twitch"
)
func TestViewDisplaysSplitPaneWithSelectedStreamerDetails(t *testing.T) {
model := New(Options{
Streamers: []twitch.StreamerEntry{
{ConfigLogin: "alpha", Login: "alpha_live", ChannelID: "1", Live: true, Status: twitch.StreamerReady},
{ConfigLogin: "beta", Login: "beta_live", ChannelID: "2", Live: true, Status: twitch.StreamerReady},
{ConfigLogin: "gamma", Status: twitch.StreamerLoading},
},
})
model.mode = streamerView
model.viewer = &twitch.Viewer{ID: "7", Login: "viewer"}
model.width = 80
type fakeModelRuntime struct {
authStarted bool
resolveStarted *auth.State
}
got := model.View()
if !strings.Contains(got, "Watching: alpha_live, beta_live\n\n") {
t.Fatalf("View() missing watching summary:\n%s", got)
}
if !strings.Contains(got, "> alpha_live [active]") {
t.Fatalf("View() missing selected active row:\n%s", got)
}
if !strings.Contains(got, "gamma [inactive]") {
t.Fatalf("View() missing inactive row:\n%s", got)
}
if !strings.Contains(got, "IRC Chat") || !strings.Contains(got, "not joined") {
t.Fatalf("View() missing detail pane:\n%s", got)
}
func (f *fakeModelRuntime) startAuth(ch chan<- AuthUpdate) {
f.authStarted = true
close(ch)
}
func (f *fakeModelRuntime) startResolve(state *auth.State, ch chan<- StreamerUpdate) {
f.resolveStarted = state
close(ch)
}
func TestViewDisplaysDashboardWithSelectedStreamerDetails(t *testing.T) {
model := dashboardModel(
twitch.StreamerEntry{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady},
twitch.StreamerEntry{ConfigLogin: "beta", Login: "beta_live", Live: true, Status: twitch.StreamerReady},
twitch.StreamerEntry{ConfigLogin: "gamma", Status: twitch.StreamerLoading},
)
assertContainsAll(t, model.View(),
"Watching: alpha_live, beta_live",
"alpha_live",
"live | irc idle",
"gamma",
"loading",
"IRC Chat",
"not joined",
)
}
func TestAuthUpdateAppendsLogLine(t *testing.T) {
model := New(Options{Streamers: twitch.LoadingStreamerEntries([]string{"alpha"})})
updated, cmd := model.Update(AuthUpdate{Line: "Open page: https://www.twitch.tv/activate"})
updated, cmd := New(Options{Streamers: []string{"alpha"}}).Update(AuthUpdate{Line: "Open page: https://www.twitch.tv/activate"})
if cmd != nil {
t.Fatal("expected nil cmd after auth update without channel")
}
next := updated.(Model)
got := next.View()
want := "Twitch Login\nOpen page: https://www.twitch.tv/activate\n\n"
if got != want {
t.Fatalf("View() = %q, want %q", got, want)
}
assertContainsAll(t, updated.(Model).View(), "Twitch Login", "Open page: https://www.twitch.tv/activate")
}
func TestAuthSuccessSwitchesToStreamerViewAndStartsResolution(t *testing.T) {
var started *auth.State
model := New(Options{
Streamers: twitch.LoadingStreamerEntries([]string{"alpha"}),
StartResolve: func(state *auth.State, ch chan<- StreamerUpdate) {
started = state
close(ch)
},
})
runtime := &fakeModelRuntime{}
state := &auth.State{Login: "viewer", UserID: "7"}
updated, cmd := model.Update(AuthUpdate{
State: state,
Done: true,
})
updated, cmd := New(Options{Streamers: []string{"alpha"}, runtime: runtime}).Update(AuthUpdate{State: state, Done: true})
if cmd == nil {
t.Fatal("expected streamer resolution command")
}
if _, ok := cmd().(streamerStartedMsg); !ok {
t.Fatalf("cmd() returned %T, want streamerStartedMsg", cmd())
}
if started != state {
t.Fatalf("started state = %#v, want %#v", started, state)
if runtime.resolveStarted != state {
t.Fatalf("started state = %#v, want %#v", runtime.resolveStarted, state)
}
next := updated.(Model)
got := next.View()
if !strings.Contains(got, "Watching: no live streamers") {
t.Fatalf("View() = %q", got)
}
assertContainsAll(t, next.View(), "Watching: no live streamers")
if next.selectedConfig != "alpha" {
t.Fatalf("selectedConfig = %q, want alpha", next.selectedConfig)
}
}
func TestInitStartsResolutionWhenAlreadyAuthenticated(t *testing.T) {
var started *auth.State
state := &auth.State{Login: "viewer", UserID: "7"}
model := New(Options{
Streamers: twitch.LoadingStreamerEntries([]string{"alpha"}),
AuthState: state,
StartResolve: func(got *auth.State, ch chan<- StreamerUpdate) {
started = got
close(ch)
},
})
func TestInitStartsAuthOrResolution(t *testing.T) {
runtime := &fakeModelRuntime{}
if _, ok := New(Options{Streamers: []string{"alpha"}, runtime: runtime}).Init()().(authStartedMsg); !ok {
t.Fatal("unauthenticated Init() did not start auth")
}
if !runtime.authStarted {
t.Fatal("expected auth runtime to start")
}
cmd := model.Init()
if cmd == nil {
t.Fatal("expected init command")
state := &auth.State{Login: "viewer", UserID: "7"}
runtime = &fakeModelRuntime{}
if _, ok := New(Options{Streamers: []string{"alpha"}, AuthState: state, runtime: runtime}).Init()().(streamerStartedMsg); !ok {
t.Fatal("authenticated Init() did not start streamer resolution")
}
if _, ok := cmd().(streamerStartedMsg); !ok {
t.Fatalf("cmd() returned %T, want streamerStartedMsg", cmd())
}
if started != state {
t.Fatalf("started state = %#v, want %#v", started, state)
if runtime.resolveStarted != state {
t.Fatalf("started state = %#v, want %#v", runtime.resolveStarted, state)
}
}
func TestStreamerUpdateAppliesEntry(t *testing.T) {
model := New(Options{
Streamers: twitch.LoadingStreamerEntries([]string{"alpha"}),
AuthState: &auth.State{Login: "viewer"},
})
model := New(Options{Streamers: []string{"alpha"}, AuthState: &auth.State{Login: "viewer"}})
model.mode = streamerView
updated, cmd := model.Update(StreamerUpdate{
@@ -135,39 +111,29 @@ func TestStreamerUpdateAppliesEntry(t *testing.T) {
}
next := updated.(Model)
assertContainsAll(t, next.View(), "alpha_live", "live")
if next.selectedConfig != "alpha" {
t.Fatalf("selectedConfig = %q, want alpha", next.selectedConfig)
}
}
func TestViewDisplaysInactiveDetailForOfflineStreamer(t *testing.T) {
model := New(Options{
Streamers: []twitch.StreamerEntry{
{ConfigLogin: "alpha", Login: "alpha_live", ChannelID: "1", Status: twitch.StreamerReady},
},
})
model.mode = streamerView
model.viewer = &twitch.Viewer{ID: "7", Login: "viewer"}
model.width = 80
got := model.View()
if !strings.Contains(got, "inactive") {
t.Fatalf("View() = %q", got)
}
model := dashboardModel(twitch.StreamerEntry{ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady})
assertContainsAll(t, model.View(), "offline", "inactive")
}
func TestActiveStreamersRenderBeforeInactiveInConfigOrder(t *testing.T) {
model := New(Options{
Streamers: []twitch.StreamerEntry{
{ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady},
{ConfigLogin: "beta", Login: "beta_live", Live: true, Status: twitch.StreamerReady},
{ConfigLogin: "gamma", Login: "gamma_live", Live: true, Status: twitch.StreamerReady},
{ConfigLogin: "delta", Status: twitch.StreamerLoading},
},
})
model := New(Options{initialStreamers: []twitch.StreamerEntry{
{ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady},
{ConfigLogin: "beta", Login: "beta_live", Live: true, Status: twitch.StreamerReady},
{ConfigLogin: "gamma", Login: "gamma_live", Live: true, Status: twitch.StreamerReady},
{ConfigLogin: "delta", Status: twitch.StreamerLoading},
}})
rows := model.orderedRows()
got := []string{rows[0].entry.ConfigLogin, rows[1].entry.ConfigLogin, rows[2].entry.ConfigLogin, rows[3].entry.ConfigLogin}
got := []string{}
for _, entry := range model.orderedStreamers() {
got = append(got, entry.ConfigLogin)
}
want := []string{"beta", "gamma", "alpha", "delta"}
for index := range want {
if got[index] != want[index] {
@@ -178,7 +144,7 @@ func TestActiveStreamersRenderBeforeInactiveInConfigOrder(t *testing.T) {
func TestSelectionTracksSameStreamerAcrossReorder(t *testing.T) {
model := New(Options{
Streamers: []twitch.StreamerEntry{
initialStreamers: []twitch.StreamerEntry{
{ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady},
{ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady},
},
@@ -189,33 +155,19 @@ func TestSelectionTracksSameStreamerAcrossReorder(t *testing.T) {
updated, _ := model.Update(StreamerUpdate{
Index: 1,
Entry: &twitch.StreamerEntry{
ConfigLogin: "beta",
Login: "beta_live",
Live: true,
Status: twitch.StreamerReady,
},
Entry: &twitch.StreamerEntry{ConfigLogin: "beta", Login: "beta_live", Live: true, Status: twitch.StreamerReady},
})
next := updated.(Model)
if next.selectedConfig != "beta" {
t.Fatalf("selectedConfig = %q, want beta", next.selectedConfig)
}
if next.selectedRowIndex(next.orderedRows()) != 0 {
t.Fatalf("selected row index = %d, want 0", next.selectedRowIndex(next.orderedRows()))
if next.selectedConfig != "beta" || next.selectedRowIndex(next.orderedStreamers()) != 0 {
t.Fatalf("selection moved after reorder: %q at %d", next.selectedConfig, next.selectedRowIndex(next.orderedStreamers()))
}
}
func TestUpDownNavigationMovesSelectedStreamer(t *testing.T) {
model := New(Options{
Streamers: []twitch.StreamerEntry{
{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady},
{ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady},
},
AuthState: &auth.State{Login: "viewer"},
})
model.mode = streamerView
model.selectedConfig = "alpha"
model := dashboardModel(
twitch.StreamerEntry{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady},
twitch.StreamerEntry{ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady},
)
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown})
next := updated.(Model)
@@ -231,49 +183,47 @@ func TestUpDownNavigationMovesSelectedStreamer(t *testing.T) {
}
func TestIRCUpdatesShowJoinedStatusOnly(t *testing.T) {
model := New(Options{
Streamers: []twitch.StreamerEntry{
{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady},
},
AuthState: &auth.State{Login: "viewer"},
})
model.mode = streamerView
model.width = 80
updated, _ := model.Update(StreamerUpdate{
IRC: &IRCUpdate{
Login: "alpha_live",
State: IRCJoined,
},
})
updated, _ := dashboardModel(twitch.StreamerEntry{
ConfigLogin: "alpha",
Login: "alpha_live",
Live: true,
Status: twitch.StreamerReady,
}).Update(StreamerUpdate{IRC: &IRCUpdate{Login: "alpha_live", State: IRCJoined}})
next := updated.(Model)
detail := next.ircDetails["alpha_live"]
if !detail.joined {
if !next.ircDetails["alpha_live"].joined {
t.Fatal("expected joined detail")
}
if !strings.Contains(next.View(), greenDot) {
t.Fatalf("View() missing green status dot:\n%s", next.View())
}
assertContainsAll(t, next.View(), "joined")
}
func TestWindowSizeClipsListAroundSelection(t *testing.T) {
model := New(Options{
Streamers: []twitch.StreamerEntry{
{ConfigLogin: "alpha", Status: twitch.StreamerReady},
{ConfigLogin: "beta", Status: twitch.StreamerReady},
{ConfigLogin: "gamma", Status: twitch.StreamerReady},
{ConfigLogin: "delta", Status: twitch.StreamerReady},
},
AuthState: &auth.State{Login: "viewer"},
})
model.mode = streamerView
func TestWindowSizeKeepsSelectionVisible(t *testing.T) {
model := dashboardModel(
twitch.StreamerEntry{ConfigLogin: "alpha", Status: twitch.StreamerReady},
twitch.StreamerEntry{ConfigLogin: "beta", Status: twitch.StreamerReady},
twitch.StreamerEntry{ConfigLogin: "gamma", Status: twitch.StreamerReady},
twitch.StreamerEntry{ConfigLogin: "delta", Status: twitch.StreamerReady},
)
model.selectedConfig = "delta"
updated, _ := model.Update(tea.WindowSizeMsg{Width: 60, Height: 6})
next := updated.(Model)
got := next.View()
if !strings.Contains(got, "> delta [inactive]") {
t.Fatalf("View() missing selected row after clipping:\n%s", got)
updated, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 12})
assertContainsAll(t, updated.(Model).View(), "delta")
}
func dashboardModel(entries ...twitch.StreamerEntry) Model {
model := New(Options{initialStreamers: entries, AuthState: &auth.State{Login: "viewer"}})
model.mode = streamerView
model.viewer = &twitch.Viewer{ID: "7", Login: "viewer"}
model.width = 100
model.height = 28
return model
}
func assertContainsAll(t *testing.T, got string, wants ...string) {
t.Helper()
for _, want := range wants {
if !strings.Contains(got, want) {
t.Fatalf("missing %q in:\n%s", want, got)
}
}
}
+298
View File
@@ -0,0 +1,298 @@
package tui
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"parasocial/internal/auth"
"parasocial/internal/gql"
"parasocial/internal/irc"
"parasocial/internal/twitch"
)
const streamerRefreshInterval = 5 * time.Minute
// Options configures the TUI runtime.
type Options struct {
Streamers []string
AuthState *auth.State
AuthPath string
Input io.Reader
Output io.Writer
runtime modelRuntime
initialStreamers []twitch.StreamerEntry
httpClient *http.Client
}
type runtime struct {
ctx context.Context
logins []string
httpClient *http.Client
authClient *auth.Client
authPath string
refreshEvery time.Duration
sleep func(context.Context, time.Duration) error
}
// Run starts the Bubble Tea UI and keeps application orchestration inside the TUI package.
func Run(ctx context.Context, options Options) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
rt := newRuntime(ctx, options)
state := options.AuthState
if state == nil {
var err error
state, err = rt.reuseAuth()
if err != nil {
return err
}
}
options.AuthState = state
options.runtime = rt
input := options.Input
if input == nil {
input = os.Stdin
}
output := options.Output
if output == nil {
output = os.Stdout
}
program := tea.NewProgram(
New(options),
tea.WithContext(ctx),
tea.WithInput(input),
tea.WithOutput(output),
)
_, err := program.Run()
return err
}
func newRuntime(ctx context.Context, options Options) *runtime {
httpClient := options.httpClient
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
authPath := options.AuthPath
if authPath == "" {
authPath = auth.DefaultPath()
}
return &runtime{
ctx: ctx,
logins: append([]string(nil), options.Streamers...),
httpClient: httpClient,
authClient: auth.NewClient(httpClient),
authPath: authPath,
refreshEvery: streamerRefreshInterval,
sleep: sleepContext,
}
}
func (r *runtime) reuseAuth() (*auth.State, error) {
return r.authClient.ReuseAuth(r.ctx, r.authPath)
}
func (r *runtime) startAuth(ch chan<- AuthUpdate) {
go func() {
defer close(ch)
state, err := r.authClient.EnsureAuth(r.ctx, r.authPath, func(line string) {
ch <- AuthUpdate{Line: strings.TrimRight(line, "\n")}
})
if err != nil {
ch <- AuthUpdate{
Line: fmt.Sprintf("Authentication failed: %v", err),
Err: err,
Done: true,
}
return
}
ch <- AuthUpdate{State: state, Done: true}
}()
}
func (r *runtime) startResolve(state *auth.State, ch chan<- StreamerUpdate) {
go func() {
defer close(ch)
ircManager := newIRCManager(ch)
defer ircManager.Close()
service, err := newTwitchService(r.httpClient, state)
if err != nil {
ch <- StreamerUpdate{Err: err, Done: true}
return
}
if err := resolveStreamerEntriesWithSleep(r.ctx, service, state, r.logins, ircManager, func(update StreamerUpdate) {
ch <- update
}, r.refreshEvery, r.sleep); err != nil {
if errors.Is(err, context.Canceled) {
return
}
ch <- StreamerUpdate{Err: err, Done: true}
return
}
ch <- StreamerUpdate{Done: true}
}()
}
type streamerService interface {
CurrentUser(context.Context) (*twitch.Viewer, error)
ResolveStreamer(context.Context, string) (*twitch.Channel, error)
StreamInfo(context.Context, string) (*twitch.StreamInfo, error)
PlaybackAccessToken(context.Context, string) (*twitch.PlaybackToken, error)
}
type ircSyncer interface {
Sync(context.Context, string, string, []irc.Target)
}
func newTwitchService(httpClient *http.Client, state *auth.State) (*twitch.Service, error) {
client := &gql.Client{
HTTPClient: httpClient,
Session: gql.Session{
AccessToken: state.AccessToken,
ClientID: state.ClientID,
DeviceID: state.DeviceID,
UserAgent: auth.TVUserAgent(),
},
}
if err := client.Validate(); err != nil {
return nil, fmt.Errorf("configure graphql session: %w", err)
}
return &twitch.Service{GQL: client}, nil
}
func newIRCManager(ch chan<- StreamerUpdate) *irc.Manager {
return &irc.Manager{
Addr: irc.DefaultAddr,
Events: func(event irc.Event) {
ch <- StreamerUpdate{
IRC: &IRCUpdate{
Login: event.Streamer,
State: IRCState(event.State),
Line: event.Line,
},
}
},
}
}
func resolveStreamerEntries(ctx context.Context, service streamerService, state *auth.State, logins []string, syncer ircSyncer, send func(StreamerUpdate)) error {
return resolveStreamerEntriesWithSleep(ctx, service, state, logins, syncer, send, streamerRefreshInterval, sleepContext)
}
func resolveStreamerEntriesWithSleep(ctx context.Context, service streamerService, state *auth.State, logins []string, syncer ircSyncer, send func(StreamerUpdate), interval time.Duration, sleep func(context.Context, time.Duration) error) error {
viewer, err := service.CurrentUser(ctx)
if err != nil {
return fmt.Errorf("resolve current user: %w", err)
}
send(StreamerUpdate{Viewer: viewer})
entries := twitch.LoadingStreamerEntries(logins)
syncWatchedChannels(ctx, syncer, state, entries)
for {
for index, login := range logins {
entry, err := resolveStreamerEntry(ctx, service, login)
if errors.Is(err, context.Canceled) {
return err
}
entries[index] = *entry
send(StreamerUpdate{
Index: index,
Entry: entry,
})
syncWatchedChannels(ctx, syncer, state, entries)
}
if err := sleep(ctx, interval); err != nil {
return err
}
}
}
func resolveStreamerEntry(ctx context.Context, service streamerService, login string) (*twitch.StreamerEntry, error) {
entry := &twitch.StreamerEntry{
ConfigLogin: login,
Status: twitch.StreamerLoading,
}
channel, err := service.ResolveStreamer(ctx, login)
switch {
case err == nil:
entry.Login = channel.Login
entry.ChannelID = channel.ID
case errors.Is(err, context.Canceled):
return nil, err
default:
entry.Status = twitch.StreamerError
entry.Error = err.Error()
return entry, nil
}
stream, err := service.StreamInfo(ctx, channel.ID)
switch {
case err == nil:
entry.Status = twitch.StreamerReady
entry.Live = stream.Online
case errors.Is(err, context.Canceled):
return nil, err
default:
entry.Status = twitch.StreamerError
entry.Error = err.Error()
return entry, nil
}
if !entry.Live {
return entry, nil
}
if _, err := service.PlaybackAccessToken(ctx, channel.Login); err != nil && errors.Is(err, context.Canceled) {
return nil, err
}
return entry, nil
}
func sleepContext(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func syncWatchedChannels(ctx context.Context, syncer ircSyncer, state *auth.State, entries []twitch.StreamerEntry) {
if syncer == nil || state == nil {
return
}
syncer.Sync(ctx, state.Login, state.AccessToken, watchedIRCTargets(entries))
}
func watchedIRCTargets(entries []twitch.StreamerEntry) []irc.Target {
targets := make([]irc.Target, 0, 2)
for _, entry := range entries {
if entry.Status != twitch.StreamerReady || !entry.Live || entry.Login == "" {
continue
}
targets = append(targets, irc.Target{Login: entry.Login})
if len(targets) == 2 {
break
}
}
return targets
}
+236
View File
@@ -0,0 +1,236 @@
package tui
import (
"context"
"errors"
"reflect"
"testing"
"time"
"parasocial/internal/auth"
"parasocial/internal/irc"
"parasocial/internal/twitch"
)
type fakeStreamerService struct {
channels map[string]*twitch.Channel
resolveErrs map[string]error
streams map[string][]streamResult
streamCalls map[string]int
playbackErr map[string]error
}
type streamResult struct {
info *twitch.StreamInfo
err error
}
func (f *fakeStreamerService) CurrentUser(context.Context) (*twitch.Viewer, error) {
return &twitch.Viewer{ID: "7", Login: "viewer"}, nil
}
func (f *fakeStreamerService) ResolveStreamer(_ context.Context, login string) (*twitch.Channel, error) {
if err, ok := f.resolveErrs[login]; ok {
return nil, err
}
return f.channels[login], nil
}
func (f *fakeStreamerService) StreamInfo(_ context.Context, channelID string) (*twitch.StreamInfo, error) {
call := f.streamCalls[channelID]
f.streamCalls[channelID] = call + 1
results := f.streams[channelID]
if len(results) == 0 {
return &twitch.StreamInfo{Online: false}, nil
}
if call >= len(results) {
call = len(results) - 1
}
if results[call].err != nil {
return nil, results[call].err
}
return results[call].info, nil
}
func (f *fakeStreamerService) PlaybackAccessToken(_ context.Context, login string) (*twitch.PlaybackToken, error) {
if err, ok := f.playbackErr[login]; ok {
return nil, err
}
return &twitch.PlaybackToken{Signature: "sig", Value: "token"}, nil
}
type fakeIRCSyncer struct {
calls [][]string
}
func (f *fakeIRCSyncer) Sync(_ context.Context, _, _ string, targets []irc.Target) {
logins := make([]string, 0, len(targets))
for _, target := range targets {
logins = append(logins, target.Login)
}
f.calls = append(f.calls, logins)
}
func TestResolveStreamerEntries(t *testing.T) {
service := streamService(
channel("alpha", "1", "alpha_live"),
resolveErr("beta", errors.New("lookup failed")),
live("1", true),
)
updates := collectUpdates(t, service, []string{"alpha", "beta"}, nil, cancelAfter(0))
if len(updates) != 3 {
t.Fatalf("len(updates) = %d", len(updates))
}
if updates[0].Viewer == nil || updates[0].Viewer.Login != "viewer" {
t.Fatalf("viewer update = %#v", updates[0])
}
if entry := updates[1].Entry; entry == nil || entry.Status != twitch.StreamerReady || entry.Login != "alpha_live" || !entry.Live {
t.Fatalf("alpha update = %#v", updates[1])
}
if entry := updates[2].Entry; entry == nil || entry.Status != twitch.StreamerError {
t.Fatalf("beta update = %#v", updates[2])
}
}
func TestResolveStreamerEntriesPlaybackFailureStillMarksLive(t *testing.T) {
updates := collectUpdates(t, streamService(
channel("alpha", "1", "alpha_live"),
live("1", true),
playbackErr("alpha_live", errors.New("token lookup failed")),
), []string{"alpha"}, nil, cancelAfter(0))
if entry := updates[1].Entry; entry == nil || entry.Status != twitch.StreamerReady || !entry.Live {
t.Fatalf("entry = %#v", entry)
}
}
func TestResolveStreamerEntriesRefreshesLiveState(t *testing.T) {
updates := collectUpdates(t, streamService(
channel("alpha", "1", "alpha_live"),
streams("1", false, true),
), []string{"alpha"}, nil, cancelAfter(1))
if len(updates) != 3 {
t.Fatalf("len(updates) = %d", len(updates))
}
if updates[1].Entry == nil || updates[1].Entry.Live {
t.Fatalf("first pass = %#v", updates[1])
}
if updates[2].Entry == nil || !updates[2].Entry.Live {
t.Fatalf("second pass = %#v", updates[2])
}
}
func TestResolveStreamerEntriesSyncsTopTwoLiveChannels(t *testing.T) {
syncer := &fakeIRCSyncer{}
collectUpdates(t, streamService(
channel("alpha", "1", "alpha_live"),
channel("beta", "2", "beta_live"),
channel("gamma", "3", "gamma_live"),
live("1", true),
live("2", true),
live("3", true),
), []string{"alpha", "beta", "gamma"}, syncer, cancelAfter(0))
assertSyncCalls(t, syncer.calls, [][]string{
{},
{"alpha_live"},
{"alpha_live", "beta_live"},
{"alpha_live", "beta_live"},
})
}
func TestResolveStreamerEntriesRefreshUpdatesWatchedChannels(t *testing.T) {
syncer := &fakeIRCSyncer{}
collectUpdates(t, streamService(
channel("alpha", "1", "alpha_live"),
channel("beta", "2", "beta_live"),
streams("1", true, false),
streams("2", false, true),
), []string{"alpha", "beta"}, syncer, cancelAfter(1))
assertSyncCalls(t, syncer.calls, [][]string{
{},
{"alpha_live"},
{"alpha_live"},
{},
{"beta_live"},
})
}
type serviceOption func(*fakeStreamerService)
func streamService(options ...serviceOption) *fakeStreamerService {
service := &fakeStreamerService{
channels: map[string]*twitch.Channel{},
resolveErrs: map[string]error{},
streams: map[string][]streamResult{},
streamCalls: map[string]int{},
playbackErr: map[string]error{},
}
for _, option := range options {
option(service)
}
return service
}
func channel(login, id, resolved string) serviceOption {
return func(service *fakeStreamerService) {
service.channels[login] = &twitch.Channel{ID: id, Login: resolved}
}
}
func resolveErr(login string, err error) serviceOption {
return func(service *fakeStreamerService) {
service.resolveErrs[login] = err
}
}
func live(channelID string, online bool) serviceOption {
return streams(channelID, online)
}
func streams(channelID string, online ...bool) serviceOption {
return func(service *fakeStreamerService) {
for _, value := range online {
service.streams[channelID] = append(service.streams[channelID], streamResult{info: &twitch.StreamInfo{Online: value}})
}
}
}
func playbackErr(login string, err error) serviceOption {
return func(service *fakeStreamerService) {
service.playbackErr[login] = err
}
}
func collectUpdates(t *testing.T, service *fakeStreamerService, logins []string, syncer ircSyncer, sleep func(context.Context, time.Duration) error) []StreamerUpdate {
t.Helper()
var updates []StreamerUpdate
err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, logins, syncer, func(update StreamerUpdate) {
updates = append(updates, update)
}, 0, sleep)
if !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
return updates
}
func cancelAfter(allowedSleeps int) func(context.Context, time.Duration) error {
calls := 0
return func(context.Context, time.Duration) error {
calls++
if calls <= allowedSleeps {
return nil
}
return context.Canceled
}
}
func assertSyncCalls(t *testing.T, got, want [][]string) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Fatalf("sync calls = %#v, want %#v", got, want)
}
}
+54
View File
@@ -0,0 +1,54 @@
package tui
import (
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/lipgloss"
)
var (
pageStyle = lipgloss.NewStyle().
Padding(1, 2)
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#E6EDF3"))
mutedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6E7681"))
accentStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7CE38B"))
warnStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#F0883E"))
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF7B72"))
panelStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("#30363D")).
Padding(1, 2)
labelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#8B949E")).
Bold(true)
statusLiveStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7CE38B"))
statusIdleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#8B949E"))
statusErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF7B72"))
rowNameStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#C9D1D9")).
PaddingLeft(1)
rowDescStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#8B949E")).
PaddingLeft(1)
selectedRowNameStyle = rowNameStyle.
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(lipgloss.Color("#7CE38B")).
Foreground(lipgloss.Color("#E6EDF3"))
selectedRowDescStyle = rowDescStyle.
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(lipgloss.Color("#7CE38B"))
)
func newAuthViewport(width, height int) viewport.Model {
vp := viewport.New(width, height)
vp.Style = lipgloss.NewStyle()
return vp
}
+239
View File
@@ -0,0 +1,239 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"parasocial/internal/twitch"
)
// View renders either the login log screen or the authenticated streamer dashboard.
func (m Model) View() string {
if m.mode == authView {
return m.renderAuthView()
}
return m.renderStreamerView()
}
func (m Model) renderAuthView() string {
body := []string{
titleStyle.Render("Twitch Login"),
mutedStyle.Render("Complete the device authorization flow to continue."),
"",
panelStyle.Width(contentWidth(m.width)).Render(m.authViewport.View()),
}
if m.authErr != nil {
body = append(body, "", errorStyle.Render("Login did not complete. Press q to quit."))
}
return pageStyle.Render(strings.Join(body, "\n")) + "\n"
}
func (m Model) renderStreamerView() string {
header := m.renderDashboardHeader()
left := panelStyle.
Width(streamerListWidth(m.width)).
Height(panelHeight(m.height)).
Render(labelStyle.Render("Streamers") + "\n" + m.renderStreamerRows())
right := panelStyle.
Width(detailWidth(m.width)).
Height(panelHeight(m.height)).
Render(m.renderDetailPanel())
body := lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
return pageStyle.Render(header+"\n\n"+body) + "\n"
}
func (m Model) renderDashboardHeader() string {
var identity string
switch {
case m.viewer != nil:
identity = fmt.Sprintf("Logged in as %s (%s)", m.viewer.Login, m.viewer.ID)
case m.authState != nil && m.authState.Login != "":
identity = fmt.Sprintf("Logged in as %s", m.authState.Login)
default:
identity = "Logged in"
}
summary := "Watching: " + watchingSummary(m.streamers)
if m.resolveErr != nil {
summary += " " + warnStyle.Render(fmt.Sprintf("Viewer lookup failed: %v", m.resolveErr))
} else if m.viewer == nil {
summary += " " + mutedStyle.Render("Resolving viewer identity...")
}
return titleStyle.Render(identity) + "\n" + mutedStyle.Render(summary)
}
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")
}
entry := entries[selected]
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 !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) renderStreamerRows() string {
entries := m.visibleStreamers()
if len(entries) == 0 {
return mutedStyle.Render("none")
}
lines := make([]string, 0, len(entries)*2)
for _, entry := range entries {
nameStyle, descStyle := rowNameStyle, rowDescStyle
if entry.ConfigLogin == m.selectedConfig {
nameStyle, descStyle = selectedRowNameStyle, selectedRowDescStyle
}
detail := m.ircDetails[normalizeKey(entry.Login)]
lines = append(lines,
nameStyle.Render(streamerName(entry)),
descStyle.Render(rawStatus(entry)+" | "+ircSummary(detail)),
)
}
return strings.Join(lines, "\n")
}
func (m Model) visibleStreamers() []twitch.StreamerEntry {
entries := m.orderedStreamers()
visible := max(1, streamerListHeight(m.height)/2)
if len(entries) <= visible {
return entries
}
selected := max(0, m.selectedRowIndex(entries))
start := selected - visible + 1
if start < 0 {
start = 0
}
if start+visible > len(entries) {
start = len(entries) - visible
}
return entries[start : start+visible]
}
func watchingSummary(streamers []twitch.StreamerEntry) string {
live := make([]string, 0, 2)
for _, streamer := range streamers {
if streamer.Status != twitch.StreamerReady || !streamer.Live {
continue
}
name := streamer.Login
if name == "" {
name = streamer.ConfigLogin
}
live = append(live, name)
if len(live) == 2 {
break
}
}
if len(live) == 0 {
return "no live streamers"
}
return strings.Join(live, ", ")
}
func statusText(entry twitch.StreamerEntry) string {
switch {
case entry.Status == twitch.StreamerError:
return statusErrorStyle.Render("error")
case entry.Status == twitch.StreamerLoading:
return mutedStyle.Render("loading")
case entry.Live:
return statusLiveStyle.Render("live")
default:
return statusIdleStyle.Render("offline")
}
}
func rawStatus(entry twitch.StreamerEntry) string {
switch {
case entry.Status == twitch.StreamerError:
return "error"
case entry.Status == twitch.StreamerLoading:
return "loading"
case entry.Live:
return "live"
default:
return "offline"
}
}
func ircSummary(detail ircDetail) string {
if detail.joined {
return "irc joined"
}
return "irc idle"
}
func contentWidth(width int) int {
if width <= 0 {
return defaultViewWidth
}
return max(24, width-4)
}
func authViewportHeight(height int) int {
if height <= 0 {
return 8
}
return max(4, height-8)
}
func streamerListWidth(width int) int {
if width <= 0 {
return 34
}
if width < 72 {
return max(24, width/2-5)
}
return min(40, max(30, width/3))
}
func detailWidth(width int) int {
if width <= 0 {
return defaultViewWidth - streamerListWidth(width) - 7
}
return max(24, width-streamerListWidth(width)-11)
}
func streamerListHeight(height int) int {
if height <= 0 {
return 14
}
return max(4, height-10)
}
func panelHeight(height int) int {
if height <= 0 {
return 16
}
return max(6, height-8)
}