1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
|
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/humdrum-tiv/sportsball/internal/model"
)
// Layout: width of the row prefix (cursor + rank + star + abbr + name) before
// the stat columns begin. Header and rows share it so columns line up.
const (
stNameW = 18
stValW = 6 // right-aligned width per stat column
stLeftW = 2 + 2 + 1 + 1 + 1 + 3 + 1 + stNameW
)
// standingsView renders a league's standings: per-group ranked tables with a
// movable team cursor. enter on a row opens that team's schedule.
func (a App) standingsView() string {
l, _ := model.LeagueByID(a.standingsLeague)
title := styleTitle.Render(" STANDINGS ") + " " +
lipgloss.NewStyle().Foreground(teamColor(l.Color)).Bold(true).Render(l.Icon+" "+l.Name)
tabs := a.standingsTabs()
footer := styleHelp.Render(strings.Join([]string{
keys.Up.Help().Key + "/" + keys.Down.Help().Key + " move",
keys.NextLg.Help().Key + " league",
keys.Enter.Help().Key + " schedule",
"f/F fav",
keys.Refresh.Help().Key + " refresh",
keys.Back.Help().Key + " back",
}, styleFaint.Render(" · ")))
frame := func(body string) string {
return styleApp.Render(strings.Join([]string{title, tabs, "", body, "", footer}, "\n"))
}
switch {
case a.standingsErr != nil:
return frame(styleSubtle.Render("standings unavailable — " + a.standingsErr.Error()))
case a.standings.RowCount() == 0:
return frame(styleSubtle.Render(a.spinner.View() + " loading standings…"))
}
lines, selTop := a.standingsLines()
avail := a.height - 5 // title, tabs, blank, blank, footer
if avail < 3 {
avail = 3
}
return frame(strings.Join(clampScroll(lines, selTop, avail), "\n"))
}
// standingsTabs is the league chip row for the standings view, highlighting the
// league currently shown.
func (a App) standingsTabs() string {
tabs := make([]string, 0, len(a.leagues))
for _, l := range a.leagues {
tabs = append(tabs, tab(l.Icon+" "+l.Abbr, l.ID == a.standingsLeague))
}
return strings.Join(tabs, styleFaint.Render("│"))
}
// standingsLines renders every group to lines and reports the line index of the
// selected team row, for scroll-follow.
func (a App) standingsLines() (lines []string, selTop int) {
idx := 0 // flattened team-row index, matches standingsCursor
for _, g := range a.standings.Groups {
if g.Name != "" {
lines = append(lines, ruleHeader(g.Name, stLeftW+len(g.Columns)*stValW))
}
lines = append(lines, a.standingsColHeader(g))
for _, row := range g.Rows {
if idx == a.standingsCursor {
selTop = len(lines)
}
lines = append(lines, a.standingsRow(row, idx == a.standingsCursor))
idx++
}
lines = append(lines, "")
}
return lines, selTop
}
func (a App) standingsColHeader(g model.StandingsGroup) string {
var b strings.Builder
b.WriteString(strings.Repeat(" ", stLeftW))
for _, c := range g.Columns {
b.WriteString(fmt.Sprintf("%*s", stValW, c))
}
return styleSubtle.Render(b.String())
}
func (a App) standingsRow(r model.StandingsRow, selected bool) string {
cursor := " "
if selected {
cursor = lipgloss.NewStyle().Foreground(colAccent).Bold(true).Render("▸ ")
}
rank := styleSubtle.Render(fmt.Sprintf("%2d", r.Rank))
star := " "
if a.isFav(a.standingsLeague, r.Team) {
star = lipgloss.NewStyle().Foreground(colWarn).Render("★")
}
abbr := lipgloss.NewStyle().Foreground(teamColor(r.Team.Color)).Bold(true).
Render(fmt.Sprintf("%-3s", r.Team.Abbr))
name := r.Team.Name
name = truncate(name, stNameW)
name += strings.Repeat(" ", max(0, stNameW-lipgloss.Width(name)))
nameStyle := styleScore
if selected {
nameStyle = lipgloss.NewStyle().Foreground(colText).Bold(true)
}
var vals strings.Builder
for _, v := range r.Values {
vals.WriteString(fmt.Sprintf("%*s", stValW, v))
}
return cursor + rank + " " + star + " " + abbr + " " + nameStyle.Render(name) + vals.String()
}
// clampScroll returns at most avail lines, scrolled so the line at selTop stays
// visible. For single-line rows (standings), unlike the card-aware scrollWindow.
func clampScroll(lines []string, selTop, avail int) []string {
if len(lines) <= avail {
return lines
}
off := 0
if selTop >= avail {
off = selTop - avail + 1
}
if off > len(lines)-avail {
off = len(lines) - avail
}
if off < 0 {
off = 0
}
return lines[off : off+avail]
}
|