refactor: app orchestration into tui
This commit is contained in:
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user