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] }