feat: refine tui chat and streamer panels

This commit is contained in:
2026-04-30 00:12:16 +02:00
parent f131f0bc71
commit 31639a1c36
3 changed files with 70 additions and 55 deletions
+22 -6
View File
@@ -28,22 +28,25 @@ func (f *fakeModelRuntime) startResolve(state *auth.State, ch chan<- StreamerUpd
func TestViewDisplaysDashboardWithSelectedStreamerDetails(t *testing.T) { func TestViewDisplaysDashboardWithSelectedStreamerDetails(t *testing.T) {
model := dashboardModel( model := dashboardModel(
twitch.StreamerEntry{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady}, twitch.StreamerEntry{ConfigLogin: "alpha", Login: "alpha_live", ChannelID: "1", Live: true, Status: twitch.StreamerReady},
twitch.StreamerEntry{ConfigLogin: "beta", Login: "beta_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}, twitch.StreamerEntry{ConfigLogin: "gamma", Status: twitch.StreamerLoading},
) )
assertContainsAll(t, model.View(), view := model.View()
assertContainsAll(t, view,
"Watching: alpha_live, beta_live", "Watching: alpha_live, beta_live",
"Info", "Info",
"IRC", "Chat",
"Miner", "Miner",
"live | irc idle",
"gamma", "gamma",
"loading", "alpha_live ●",
"Channel: alpha_live",
"Channel ID: 1",
"Status: live", "Status: live",
"IRC: not joined", "Chat: not joined",
) )
assertInOrder(t, view, "Channel: alpha_live", "Channel ID: 1", "Status: live", "Chat: not joined")
} }
func TestAuthUpdateAppendsLogLine(t *testing.T) { func TestAuthUpdateAppendsLogLine(t *testing.T) {
@@ -125,6 +128,19 @@ func TestViewDisplaysInactiveDetailForOfflineStreamer(t *testing.T) {
assertContainsAll(t, model.View(), "offline", "inactive") assertContainsAll(t, model.View(), "offline", "inactive")
} }
func assertInOrder(t *testing.T, value string, parts ...string) {
t.Helper()
offset := 0
for _, part := range parts {
index := strings.Index(value[offset:], part)
if index < 0 {
t.Fatalf("expected %q after offset %d in:\n%s", part, offset, value)
}
offset += index + len(part)
}
}
func TestActiveStreamersRenderBeforeInactiveInConfigOrder(t *testing.T) { func TestActiveStreamersRenderBeforeInactiveInConfigOrder(t *testing.T) {
model := New(Options{initialStreamers: []twitch.StreamerEntry{ model := New(Options{initialStreamers: []twitch.StreamerEntry{
{ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady}, {ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady},
+30 -32
View File
@@ -5,55 +5,53 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
var (
titleColor = lipgloss.Color("#E6EDF3")
mutedColor = lipgloss.Color("#8B949E")
accentColor = lipgloss.Color("#7CE38B")
warnColor = lipgloss.Color("#F0883E")
errorColor = lipgloss.Color("#FF7B72")
panelBorderColor = lipgloss.Color("#30363D")
tabActiveTextColor = lipgloss.Color("#0D1117")
tabInactiveBgColor = lipgloss.Color("#1B1F24")
rowTextColor = lipgloss.Color("#C9D1D9")
)
var ( var (
pageStyle = lipgloss.NewStyle(). pageStyle = lipgloss.NewStyle().
Padding(1, 2) Padding(1, 2)
titleStyle = lipgloss.NewStyle(). titleStyle = lipgloss.NewStyle().
Bold(true). Bold(true).
Foreground(lipgloss.Color("#E6EDF3")) Foreground(titleColor)
mutedStyle = lipgloss.NewStyle(). mutedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6E7681")) Foreground(mutedColor)
accentStyle = lipgloss.NewStyle(). accentStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7CE38B")) Foreground(accentColor)
warnStyle = lipgloss.NewStyle(). warnStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#F0883E")) Foreground(warnColor)
errorStyle = lipgloss.NewStyle(). errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF7B72")) Foreground(errorColor)
panelStyle = lipgloss.NewStyle(). panelStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()). Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("#30363D")). BorderForeground(panelBorderColor).
Padding(1, 2) Padding(1, 2)
focusedPanelStyle = panelStyle. focusedPanelStyle = panelStyle.Copy().
BorderForeground(lipgloss.Color("#7CE38B")) BorderForeground(accentColor)
labelStyle = lipgloss.NewStyle(). labelStyle = mutedStyle.Copy().
Foreground(lipgloss.Color("#8B949E")).
Bold(true) Bold(true)
activeTabStyle = lipgloss.NewStyle(). activeTabStyle = accentStyle.Copy().
Bold(true). Bold(true).
Foreground(lipgloss.Color("#0D1117")). Foreground(tabActiveTextColor).
Background(lipgloss.Color("#7CE38B")) Background(accentColor)
inactiveTabStyle = lipgloss.NewStyle(). inactiveTabStyle = mutedStyle.Copy().
Foreground(lipgloss.Color("#8B949E")). Background(tabInactiveBgColor)
Background(lipgloss.Color("#1B1F24"))
statusLiveStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7CE38B"))
statusIdleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#8B949E"))
statusErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF7B72"))
rowNameStyle = lipgloss.NewStyle(). rowNameStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#C9D1D9")). Foreground(rowTextColor).
PaddingLeft(1) PaddingLeft(1)
rowDescStyle = lipgloss.NewStyle(). selectedRowNameStyle = rowNameStyle.Copy().
Foreground(lipgloss.Color("#8B949E")).
PaddingLeft(1)
selectedRowNameStyle = rowNameStyle.
Border(lipgloss.NormalBorder(), false, false, false, true). Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(lipgloss.Color("#7CE38B")). BorderForeground(accentColor).
Foreground(lipgloss.Color("#E6EDF3")) Foreground(titleColor)
selectedRowDescStyle = rowDescStyle.
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(lipgloss.Color("#7CE38B"))
) )
func newAuthViewport(width, height int) viewport.Model { func newAuthViewport(width, height int) viewport.Model {
+18 -17
View File
@@ -96,7 +96,7 @@ func (m Model) renderDetailPanel() string {
func (m Model) renderDetailTabs() string { func (m Model) renderDetailTabs() string {
tabs := []string{ tabs := []string{
m.renderDetailTabButton(infoTab, "Info"), m.renderDetailTabButton(infoTab, "Info"),
m.renderDetailTabButton(ircTab, "IRC"), m.renderDetailTabButton(ircTab, "Chat"),
m.renderDetailTabButton(minerTab, "Miner"), m.renderDetailTabButton(minerTab, "Miner"),
} }
return lipgloss.JoinHorizontal(lipgloss.Top, tabs...) return lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
@@ -111,22 +111,23 @@ func (m Model) renderDetailTabButton(tab detailTab, label string) string {
func (m Model) renderInfoTab(entry twitch.StreamerEntry) string { func (m Model) renderInfoTab(entry twitch.StreamerEntry) string {
lines := []string{ lines := []string{
"Status: " + statusText(entry), "Channel: " + streamerName(entry),
} }
if entry.ChannelID != "" { if entry.ChannelID != "" {
lines = append(lines, "Channel ID: "+entry.ChannelID) lines = append(lines, "Channel ID: "+entry.ChannelID)
} }
lines = append(lines, "Status: "+statusText(entry))
detail := m.ircDetails[normalizeKey(entry.Login)] detail := m.ircDetails[normalizeKey(entry.Login)]
if !isActive(entry) { if !isActive(entry) {
lines = append(lines, "IRC: "+mutedStyle.Render("inactive")) lines = append(lines, "Chat: "+mutedStyle.Render("inactive"))
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
if detail.joined { if detail.joined {
lines = append(lines, "IRC: "+accentStyle.Render("joined")) lines = append(lines, "Chat: "+accentStyle.Render("joined"))
} else { } else {
lines = append(lines, "IRC: "+mutedStyle.Render("not joined")) lines = append(lines, "Chat: "+mutedStyle.Render("not joined"))
} }
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
@@ -145,24 +146,24 @@ func (m Model) renderStreamerRows() string {
return mutedStyle.Render("none") return mutedStyle.Render("none")
} }
lines := make([]string, 0, len(entries)*2) lines := make([]string, 0, len(entries))
for _, entry := range entries { for _, entry := range entries {
nameStyle, descStyle := rowNameStyle, rowDescStyle nameStyle := rowNameStyle
if entry.ConfigLogin == m.selectedConfig { if entry.ConfigLogin == m.selectedConfig {
nameStyle, descStyle = selectedRowNameStyle, selectedRowDescStyle nameStyle = selectedRowNameStyle
} }
detail := m.ircDetails[normalizeKey(entry.Login)] label := nameStyle.Render(streamerName(entry))
lines = append(lines, if isActive(entry) {
nameStyle.Render(streamerName(entry)), label += " " + accentStyle.Render("●")
descStyle.Render(rawStatus(entry)+" | "+ircSummary(detail)), }
) lines = append(lines, label)
} }
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
func (m Model) visibleStreamers() []twitch.StreamerEntry { func (m Model) visibleStreamers() []twitch.StreamerEntry {
entries := m.orderedStreamers() entries := m.orderedStreamers()
visible := max(1, streamerListHeight(m.height)/2) visible := max(1, streamerListHeight(m.height))
if len(entries) <= visible { if len(entries) <= visible {
return entries return entries
} }
@@ -202,13 +203,13 @@ func watchingSummary(streamers []twitch.StreamerEntry) string {
func statusText(entry twitch.StreamerEntry) string { func statusText(entry twitch.StreamerEntry) string {
switch { switch {
case entry.Status == twitch.StreamerError: case entry.Status == twitch.StreamerError:
return statusErrorStyle.Render("error") return errorStyle.Render("error")
case entry.Status == twitch.StreamerLoading: case entry.Status == twitch.StreamerLoading:
return mutedStyle.Render("loading") return mutedStyle.Render("loading")
case entry.Live: case entry.Live:
return statusLiveStyle.Render("live") return accentStyle.Render("live")
default: default:
return statusIdleStyle.Render("offline") return mutedStyle.Render("offline")
} }
} }