package ui import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/humdrum-tiv/sportsball/internal/config" "github.com/humdrum-tiv/sportsball/internal/model" ) // handleKey routes keystrokes by current view mode. func (a App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if key.Matches(msg, keys.Quit) { return a, tea.Quit } // Theme cycle works in every view; only cycles palettes matching the // terminal appearance (dark terminal → dark themes, light → light), and // persists the choice. if key.Matches(msg, keys.Theme) { set := themesFor(a.dark) if len(set) > 0 { pos := 0 for i, idx := range set { if idx == a.theme { pos = i break } } a.theme = set[(pos+1)%len(set)] applyTheme(themes[a.theme]) a.cfg.Theme = themes[a.theme].ID _ = config.Save(a.cfg) } return a, nil } if a.mode == viewDetail { ticker := a.tickerGames() switch { case key.Matches(msg, keys.Back): a.mode = viewDashboard case key.Matches(msg, keys.NextLg): if len(ticker) > 0 { a.tickerCursor = (a.tickerCursor + 1) % len(ticker) } case key.Matches(msg, keys.PrevLg): if len(ticker) > 0 { a.tickerCursor = (a.tickerCursor - 1 + len(ticker)) % len(ticker) } case key.Matches(msg, keys.Enter): if a.tickerCursor >= 0 && a.tickerCursor < len(ticker) { return a.openDetail(ticker[a.tickerCursor]) } case key.Matches(msg, keys.Down): a.detailScroll++ if m := a.detailScrollMax(); a.detailScroll > m { a.detailScroll = m } case key.Matches(msg, keys.Up): a.detailScroll-- if a.detailScroll < 0 { a.detailScroll = 0 } case key.Matches(msg, keys.FavAway): a.toggleFav(a.detail.League, a.detail.Away) case key.Matches(msg, keys.FavHome): a.toggleFav(a.detail.League, a.detail.Home) case key.Matches(msg, keys.Schedule): return a.openSchedule(a.detail.League, a.detail.Away) case key.Matches(msg, keys.ScheduleHome): return a.openSchedule(a.detail.League, a.detail.Home) case key.Matches(msg, keys.Refresh): if l, ok := model.LeagueByID(a.detail.League); ok { return a, tea.Batch(fetchAll(a.client, a.leagues), fetchDetail(a.client, l, a.detail.ID)) } return a, fetchAll(a.client, a.leagues) } return a, nil } if a.mode == viewSettings { return a.handleSettingsKey(msg) } if a.mode == viewStandings { return a.handleStandingsKey(msg) } if a.mode == viewSchedule { return a.handleScheduleKey(msg) } // Dashboard mode. switch { case key.Matches(msg, keys.Down): a.moveCursorVert(1) case key.Matches(msg, keys.Up): a.moveCursorVert(-1) // Arrow ←/→ move horizontally through the grid. They precede the league-nav // cases (NextLg/PrevLg also bind the arrows) so on the dashboard the arrows // move the cursor while tab/⇧tab still switch leagues. case key.Matches(msg, keys.GridLeft): a.cursor-- a.clampCursor() case key.Matches(msg, keys.GridRight): a.cursor++ a.clampCursor() case key.Matches(msg, keys.NextLg): a.cycleLeague(1) case key.Matches(msg, keys.PrevLg): a.cycleLeague(-1) case key.Matches(msg, keys.AllLg): a.filter = "" a.cursor = 0 case key.Matches(msg, keys.State): a.stateFilter = (a.stateFilter + 1) % 4 a.cursor = 0 case key.Matches(msg, keys.StateBack): a.stateFilter = (a.stateFilter + 3) % 4 a.cursor = 0 case key.Matches(msg, keys.Refresh): a.loading = true return a, fetchAll(a.client, a.leagues) case key.Matches(msg, keys.Leagues): a.mode = viewSettings a.settingsCursor = 0 a.settingsDirty = false return a, nil case key.Matches(msg, keys.FavAway): if g, ok := a.cursorGame(); ok { a.toggleFav(g.League, g.Away) } case key.Matches(msg, keys.FavHome): if g, ok := a.cursorGame(); ok { a.toggleFav(g.League, g.Home) } case key.Matches(msg, keys.Standings): // On a game, jump to that game's league standings; otherwise the active // (or first enabled) league. if g, ok := a.cursorGame(); ok { return a.openStandingsFor(g.League) } return a.openStandings() case key.Matches(msg, keys.Schedule): if g, ok := a.cursorGame(); ok { return a.openSchedule(g.League, g.Away) } case key.Matches(msg, keys.ScheduleHome): if g, ok := a.cursorGame(); ok { return a.openSchedule(g.League, g.Home) } case key.Matches(msg, keys.Enter): if g, ok := a.cursorGame(); ok { return a.openDetail(g) } } return a, nil } // cursorGame returns the game under the dashboard cursor, ok=false if none. func (a App) cursorGame() (model.Game, bool) { vis := a.visible() if a.cursor >= 0 && a.cursor < len(vis) { return vis[a.cursor], true } return model.Game{}, false } // openStandings switches to standings for the active league (or the first // enabled league when viewing all leagues). func (a App) openStandings() (tea.Model, tea.Cmd) { l := a.filter if l == "" && len(a.leagues) > 0 { l = a.leagues[0].ID } return a.openStandingsFor(l) } // openStandingsFor switches to standings for a specific league and fetches it. func (a App) openStandingsFor(l model.LeagueID) (tea.Model, tea.Cmd) { if l == "" { return a, nil } a.mode = viewStandings a.standingsLeague = l a.standingsCursor = 0 a.standings = model.Standings{} a.standingsErr = nil league, _ := model.LeagueByID(l) return a, fetchStandings(a.client, league) } // cycleStandingsLeague steps the standings view to the next/previous enabled // league and refetches. func (a App) cycleStandingsLeague(dir int) (tea.Model, tea.Cmd) { ids := a.leagueIDs() if len(ids) == 0 { return a, nil } idx := 0 for i, id := range ids { if id == a.standingsLeague { idx = i break } } a.standingsLeague = ids[(idx+dir+len(ids))%len(ids)] a.standingsCursor = 0 a.standings = model.Standings{} a.standingsErr = nil league, _ := model.LeagueByID(a.standingsLeague) return a, fetchStandings(a.client, league) } // openSchedule switches to the schedule view for one team, remembering the // current view so esc returns to it, and kicks off the fetch. func (a App) openSchedule(league model.LeagueID, t model.Team) (tea.Model, tea.Cmd) { l, ok := model.LeagueByID(league) if !ok || t.ID == "" { return a, nil } a.prevMode = a.mode a.mode = viewSchedule a.scheduleScroll = 0 a.scheduleLeague = league a.schedule = model.TeamSchedule{Team: t} // show header immediately a.scheduleErr = nil return a, fetchTeamSchedule(a.client, l, t.ID) } // handleStandingsKey drives the standings view: move the team cursor, switch // leagues, open the selected team's schedule, or close. func (a App) handleStandingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, keys.Back), key.Matches(msg, keys.Standings): a.mode = viewDashboard return a, nil case key.Matches(msg, keys.Up): a.standingsCursor-- a.clampStandingsCursor() case key.Matches(msg, keys.Down): a.standingsCursor++ a.clampStandingsCursor() case key.Matches(msg, keys.NextLg): return a.cycleStandingsLeague(1) case key.Matches(msg, keys.PrevLg): return a.cycleStandingsLeague(-1) case key.Matches(msg, keys.Refresh): league, _ := model.LeagueByID(a.standingsLeague) return a, fetchStandings(a.client, league) case key.Matches(msg, keys.FavAway), key.Matches(msg, keys.FavHome): // Standings rows are a single team — f or F both toggle the selected one. if row, ok := a.standings.RowAt(a.standingsCursor); ok { a.toggleFav(a.standingsLeague, row.Team) } case key.Matches(msg, keys.Enter): if row, ok := a.standings.RowAt(a.standingsCursor); ok { return a.openSchedule(a.standingsLeague, row.Team) } } return a, nil } // handleScheduleKey drives the schedule view: scroll, refresh, or return to // whichever view opened it. func (a App) handleScheduleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, keys.Back): a.mode = a.prevMode return a, nil case key.Matches(msg, keys.Down): a.scheduleScroll++ if m := a.scheduleScrollMax(); a.scheduleScroll > m { a.scheduleScroll = m } case key.Matches(msg, keys.Up): a.scheduleScroll-- if a.scheduleScroll < 0 { a.scheduleScroll = 0 } case key.Matches(msg, keys.Refresh): if l, ok := model.LeagueByID(a.scheduleLeague); ok && a.schedule.Team.ID != "" { return a, fetchTeamSchedule(a.client, l, a.schedule.Team.ID) } } return a, nil } // handleSettingsKey drives the leagues settings screen: move the cursor, // toggle a league on/off, reorder enabled leagues, and close (which persists // the new order and refetches the enabled set). func (a App) handleSettingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { rows, enabled := a.settingsRows() n := len(rows) switch { case key.Matches(msg, keys.Back), key.Matches(msg, keys.Leagues): return a.closeSettings() case key.Matches(msg, keys.ResetLeagues): return a.resetLeaguesToAuto() case key.Matches(msg, keys.Up): if a.settingsCursor > 0 { a.settingsCursor-- } case key.Matches(msg, keys.Down): if a.settingsCursor < n-1 { a.settingsCursor++ } case key.Matches(msg, keys.Toggle): if a.settingsCursor < n { // Keep at least one league visible — hiding the last would blank // every view (the fallback would just re-show it anyway). if enabled[a.settingsCursor] && len(a.leagues) <= 1 { return a, nil } a.toggleLeagueVisibility(rows[a.settingsCursor].ID) a.applyAutoLeagues() a.settingsDirty = true } case key.Matches(msg, keys.MoveUp): // Reorder only applies within the visible group (top rows). if a.settingsCursor > 0 && a.settingsCursor < len(a.leagues) { a.moveLeague(a.settingsCursor, -1) a.applyAutoLeagues() a.settingsCursor-- a.settingsDirty = true } case key.Matches(msg, keys.MoveDown): if a.settingsCursor < len(a.leagues)-1 { a.moveLeague(a.settingsCursor, +1) a.applyAutoLeagues() a.settingsCursor++ a.settingsDirty = true } } return a, nil } // closeSettings leaves the settings screen, persisting only on an actual change // (so merely opening settings never writes config). Drops the active filter if // its league is now hidden, then refetches. func (a App) closeSettings() (tea.Model, tea.Cmd) { a.mode = viewDashboard if a.settingsDirty { a.persistLeagues() a.settingsDirty = false } if a.filter != "" { if _, ok := a.leagueIndex()[a.filter]; !ok { a.filter = "" } } a.cursor = 0 a.loading = true return a, fetchAll(a.client, a.leagues) } // resetLeaguesToAuto clears every per-league override, returning all leagues to // pure seasonal auto-hide (display order is kept). func (a App) resetLeaguesToAuto() (tea.Model, tea.Cmd) { a.hide = map[model.LeagueID]bool{} a.show = map[model.LeagueID]bool{} a.applyAutoLeagues() a.settingsDirty = true // persist the cleared overrides on close a.settingsCursor = 0 return a, nil } // openDetail switches into the detail view for g, resetting per-game scroll, // ticker selection, and summary error, and kicking off its summary fetch. // Shared by the dashboard (open a game) and the ticker (switch games). func (a App) openDetail(g model.Game) (tea.Model, tea.Cmd) { // Switching from one open game to another leaves a back-breadcrumb so the // ticker floats the previous game to the front. Opening fresh from the // dashboard clears it. if a.mode == viewDetail && a.detail.ID != g.ID { a.detailPrev = a.detail.ID } else { a.detailPrev = "" } a.detail = g a.mode = viewDetail a.detailScroll = 0 a.tickerCursor = 0 a.detailErr = nil if l, ok := model.LeagueByID(g.League); ok { return a, fetchDetail(a.client, l, g.ID) } return a, nil } // cycleLeague steps the filter through [all, league0, league1, ...] in dir. func (a *App) cycleLeague(dir int) { order := append([]model.LeagueID{""}, a.leagueIDs()...) idx := 0 for i, id := range order { if id == a.filter { idx = i break } } idx = (idx + dir + len(order)) % len(order) a.filter = order[idx] a.cursor = 0 }