Files
parasocial/internal/tui/model.go

353 lines
7.9 KiB
Go

package tui
import (
"errors"
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"parasocial/internal/auth"
"parasocial/internal/twitch"
)
const defaultViewWidth = 80
type viewMode int
const (
authView viewMode = iota
streamerView
)
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
runtime modelRuntime
authUpdates <-chan AuthUpdate
streamerUpdates <-chan StreamerUpdate
authLogs []string
authErr error
resolveErr error
viewer *twitch.Viewer
mode viewMode
width int
height int
selectedConfig string
ircDetails map[string]ircDetail
authViewport viewport.Model
}
type ircDetail struct {
joined bool
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: streamers,
authState: options.AuthState,
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.runtime)
}
return startStreamerResolution(m.runtime, m.authState)
}
// 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:
m.authUpdates = msg.Updates
return m, waitForAuthUpdate(msg.Updates)
case streamerStartedMsg:
m.streamerUpdates = msg.Updates
return m, waitForStreamerUpdate(msg.Updates)
case AuthUpdate:
return m.updateAuth(msg)
case StreamerUpdate:
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
}
func (m Model) updateAuth(msg AuthUpdate) (tea.Model, tea.Cmd) {
if msg.Line != "" {
m.authLogs = append(m.authLogs, msg.Line)
m.syncAuthViewport()
}
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) updateStreamer(msg StreamerUpdate) (tea.Model, tea.Cmd) {
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()
m.resizeComponents()
if msg.Done {
return m, nil
}
if m.streamerUpdates != nil {
return m, waitForStreamerUpdate(m.streamerUpdates)
}
return m, nil
}
func (m *Model) applyIRCUpdate(update IRCUpdate) {
login := normalizeKey(update.Login)
if login == "" {
return
}
detail := m.ircDetails[login]
switch update.State {
case IRCJoined:
detail.joined = true
case IRCPending, IRCDisconnected:
detail.joined = false
}
if update.Line != "" {
detail.line = update.Line
}
m.ircDetails[login] = detail
}
func (m *Model) ensureSelection() {
entries := m.orderedStreamers()
if len(entries) == 0 {
m.selectedConfig = ""
return
}
if m.selectedConfig == "" {
m.selectedConfig = entries[0].ConfigLogin
return
}
for _, entry := range entries {
if entry.ConfigLogin == m.selectedConfig {
return
}
}
m.selectedConfig = entries[0].ConfigLogin
}
func (m *Model) moveSelection(delta int) {
entries := m.orderedStreamers()
if len(entries) == 0 {
m.selectedConfig = ""
return
}
selected := m.selectedRowIndex(entries) + delta
if selected < 0 {
selected = 0
}
if selected >= len(entries) {
selected = len(entries) - 1
}
m.selectedConfig = entries[selected].ConfigLogin
}
func (m Model) orderedStreamers() []twitch.StreamerEntry {
entries := make([]twitch.StreamerEntry, 0, len(m.streamers))
for _, entry := range m.streamers {
if isActive(entry) {
entries = append(entries, entry)
}
}
for _, entry := range m.streamers {
if isActive(entry) {
continue
}
entries = append(entries, entry)
}
return entries
}
func (m Model) selectedRowIndex(entries []twitch.StreamerEntry) int {
for index, entry := range entries {
if entry.ConfigLogin == m.selectedConfig {
return index
}
}
if len(entries) == 0 {
return -1
}
return 0
}
func (m *Model) resizeComponents() {
m.authViewport.Width = contentWidth(m.width)
m.authViewport.Height = authViewportHeight(m.height)
}
func (m *Model) syncAuthViewport() {
m.authViewport.SetContent(m.authLogContent())
m.authViewport.GotoBottom()
}
func (m Model) authLogContent() string {
if len(m.authLogs) == 0 {
return "Starting authentication..."
}
return strings.Join(m.authLogs, "\n")
}
func isActive(entry twitch.StreamerEntry) bool {
return entry.Status == twitch.StreamerReady && entry.Live
}
func streamerName(entry twitch.StreamerEntry) string {
if entry.Login != "" {
return entry.Login
}
return entry.ConfigLogin
}
func normalizeKey(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
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)
}
func startAuthSession(runtime modelRuntime) tea.Cmd {
return func() tea.Msg {
if runtime == nil {
return AuthUpdate{Err: errors.New("auth runtime is nil"), Done: true}
}
updates := make(chan AuthUpdate, 32)
runtime.startAuth(updates)
return authStartedMsg{Updates: updates}
}
}
func startStreamerResolution(runtime modelRuntime, state *auth.State) tea.Cmd {
return func() tea.Msg {
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)
runtime.startResolve(state, updates)
return streamerStartedMsg{Updates: updates}
}
}
func waitForAuthUpdate(updates <-chan AuthUpdate) tea.Cmd {
return func() tea.Msg {
update, ok := <-updates
if !ok {
return AuthUpdate{Err: errors.New("auth updates ended unexpectedly"), Done: true}
}
return update
}
}
func waitForStreamerUpdate(updates <-chan StreamerUpdate) tea.Cmd {
return func() tea.Msg {
update, ok := <-updates
if !ok {
return StreamerUpdate{Done: true}
}
return update
}
}