diff options
| author | Christian Cleberg <[email protected]> | 2026-04-15 22:28:45 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-15 22:28:45 -0500 |
| commit | 4cbe592e0a0fae13f0a89918a66fd983dd84464f (patch) | |
| tree | c2aac9d6e3c7b0f44adf07b86385f2ce06f1cf10 /nba | |
| parent | 00f695c765aead8aa0160a3c676cf85716178beb (diff) | |
| download | nba-scores-4cbe592e0a0fae13f0a89918a66fd983dd84464f.tar.gz nba-scores-4cbe592e0a0fae13f0a89918a66fd983dd84464f.tar.bz2 nba-scores-4cbe592e0a0fae13f0a89918a66fd983dd84464f.zip | |
feat: add Textual TUI with auto-refresh, leaders, playoff picture, and box score
Introduces an interactive terminal UI via the `--tui` flag backed by the
Textual framework. Scores and standings auto-refresh on a configurable
interval (default 60s, minimum 10s) using asyncio with nba_api calls
offloaded to a thread pool to keep the event loop unblocked.
New tabs:
- Scores / Standings (existing views, now live-updating)
- Leaders: top-25 by stat category, cycle with < / > keys
- Playoff Picture: conference seeding with clinch/elimination status
- Box Score: per-player live stats, load any game with keys 1–9
Refactored scores.py and standings.py to separate rendering from I/O
(get_scoreboard_table, get_east/west_standings_table), keeping the
existing static CLI path fully intact. Added -sc / -st shortcut flags.
Adds textual>=0.89.1,<7.0 dependency, build-system declaration for uv,
and package-data entry for the bundled .tcss stylesheet.
Diffstat (limited to 'nba')
24 files changed, 668 insertions, 66 deletions
diff --git a/nba/__pycache__/__init__.cpython-313.pyc b/nba/__pycache__/__init__.cpython-313.pyc Binary files differnew file mode 100644 index 0000000..7cfbc00 --- /dev/null +++ b/nba/__pycache__/__init__.cpython-313.pyc diff --git a/nba/__pycache__/__init__.cpython-314.pyc b/nba/__pycache__/__init__.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..11aa6a0 --- /dev/null +++ b/nba/__pycache__/__init__.cpython-314.pyc diff --git a/nba/__pycache__/box_score.cpython-313.pyc b/nba/__pycache__/box_score.cpython-313.pyc Binary files differnew file mode 100644 index 0000000..f4a42d5 --- /dev/null +++ b/nba/__pycache__/box_score.cpython-313.pyc diff --git a/nba/__pycache__/cli.cpython-313.pyc b/nba/__pycache__/cli.cpython-313.pyc Binary files differnew file mode 100644 index 0000000..860f4ba --- /dev/null +++ b/nba/__pycache__/cli.cpython-313.pyc diff --git a/nba/__pycache__/fetch_data.cpython-313.pyc b/nba/__pycache__/fetch_data.cpython-313.pyc Binary files differnew file mode 100644 index 0000000..514a7d6 --- /dev/null +++ b/nba/__pycache__/fetch_data.cpython-313.pyc diff --git a/nba/__pycache__/leaders.cpython-313.pyc b/nba/__pycache__/leaders.cpython-313.pyc Binary files differnew file mode 100644 index 0000000..fdc26de --- /dev/null +++ b/nba/__pycache__/leaders.cpython-313.pyc diff --git a/nba/__pycache__/playoff.cpython-313.pyc b/nba/__pycache__/playoff.cpython-313.pyc Binary files differnew file mode 100644 index 0000000..864faab --- /dev/null +++ b/nba/__pycache__/playoff.cpython-313.pyc diff --git a/nba/__pycache__/scores.cpython-313.pyc b/nba/__pycache__/scores.cpython-313.pyc Binary files differnew file mode 100644 index 0000000..d295067 --- /dev/null +++ b/nba/__pycache__/scores.cpython-313.pyc diff --git a/nba/__pycache__/standings.cpython-313.pyc b/nba/__pycache__/standings.cpython-313.pyc Binary files differnew file mode 100644 index 0000000..1f21ed2 --- /dev/null +++ b/nba/__pycache__/standings.cpython-313.pyc diff --git a/nba/box_score.py b/nba/box_score.py new file mode 100644 index 0000000..8c2ef01 --- /dev/null +++ b/nba/box_score.py @@ -0,0 +1,81 @@ +""" +Fetches and formats live box scores for individual games. +""" + +import json + +from tabulate import tabulate +from nba_api.live.nba.endpoints.boxscore import BoxScore + +BOLD = "\033[1m" +END = "\033[0m" + +_HEADERS = ["Player", "Pos", "Min", "Pts", "Reb", "Ast", "Stl", "Blk", "TO", "FG", "3P", "FT", "+/-"] + + +def fetch_box_score(game_id: str) -> dict: + endpoint = BoxScore(game_id=game_id) + return json.loads(endpoint.get_json()) + + +def _parse_minutes(raw: str) -> str: + """Convert 'PT35M24.00S' → '35:24'.""" + if not raw: + return "0:00" + try: + raw = raw.replace("PT", "").replace("S", "") + mins, secs = raw.split("M") + return f"{int(mins)}:{int(float(secs)):02d}" + except Exception: + return raw + + +def _player_rows(players: list) -> list: + rows = [] + for p in players: + if p.get("status") == "INACTIVE": + continue + s = p.get("statistics", {}) + rows.append([ + p.get("name", ""), + p.get("position", ""), + _parse_minutes(s.get("minutes", "")), + s.get("points", 0), + s.get("reboundsTotal", 0), + s.get("assists", 0), + s.get("steals", 0), + s.get("blocks", 0), + s.get("turnovers", 0), + f"{s.get('fieldGoalsMade', 0)}/{s.get('fieldGoalsAttempted', 0)}", + f"{s.get('threePointersMade', 0)}/{s.get('threePointersAttempted', 0)}", + f"{s.get('freeThrowsMade', 0)}/{s.get('freeThrowsAttempted', 0)}", + s.get("plusMinusPoints", 0), + ]) + return rows + + +def get_box_score_tables(data: dict) -> tuple: + """Return (home_table_str, away_table_str).""" + game = data["game"] + home = game["homeTeam"] + away = game["awayTeam"] + + home_title = ( + f"{BOLD}{home['teamCity']} {home['teamName']} " + f"{home['score']} — {away['score']} " + f"{away['teamCity']} {away['teamName']}{END} " + f" [{game.get('gameStatusText', '')}]" + ) + + home_rows = _player_rows(home.get("players", [])) + away_rows = _player_rows(away.get("players", [])) + + home_table = ( + f"{BOLD}{home['teamCity']} {home['teamName']} ({home['score']}){END}\n" + + tabulate(home_rows, headers=_HEADERS, tablefmt="grid") + ) + away_table = ( + f"{BOLD}{away['teamCity']} {away['teamName']} ({away['score']}){END}\n" + + tabulate(away_rows, headers=_HEADERS, tablefmt="grid") + ) + return home_table, away_table @@ -13,12 +13,33 @@ def nba() -> None: Parse command-line arguments and display either scoreboard or standings. """ parser = argparse.ArgumentParser(description="NBA Scoreboard and Standings") - parser.add_argument("--scores", action="store_true", help="Display the scoreboard") parser.add_argument( - "--standings", action="store_true", help="Display the standings" + "--scores", "-sc", action="store_true", help="Display the scoreboard" + ) + parser.add_argument( + "--standings", "-st", action="store_true", help="Display the standings" + ) + parser.add_argument( + "--tui", action="store_true", help="Launch the interactive TUI" + ) + parser.add_argument( + "--refresh", + type=int, + default=60, + metavar="SECONDS", + help="Auto-refresh interval in TUI mode (default: 60, minimum: 10)", ) args = parser.parse_args() + if args.tui: + from nba.tui.app import NBAApp + + initial_tab = "standings" if args.standings else "scores" + refresh_interval = max(args.refresh, 10) + NBAApp(initial_tab=initial_tab, refresh_interval=refresh_interval).run() + return + + # Legacy static mode games, ranks = fetch_data.fetch_data() if args.scores: @@ -26,4 +47,4 @@ def nba() -> None: elif args.standings: standings.build_standings(ranks) else: - print("Please specify --scores or --standings") + print("Please specify --scores or --standings (or use --tui for interactive mode)") diff --git a/nba/leaders.py b/nba/leaders.py new file mode 100644 index 0000000..aa6f3b4 --- /dev/null +++ b/nba/leaders.py @@ -0,0 +1,72 @@ +""" +Fetches and formats NBA statistical leaders. +""" + +import json + +from tabulate import tabulate +from nba_api.stats.endpoints.leagueleaders import LeagueLeaders + +BOLD = "\033[1m" +END = "\033[0m" + +# Ordered list of (api_abbreviation, display_label) +CATEGORIES = [ + ("PTS", "Points"), + ("REB", "Rebounds"), + ("AST", "Assists"), + ("STL", "Steals"), + ("BLK", "Blocks"), + ("EFF", "Efficiency"), + ("FG_PCT", "FG%"), + ("FT_PCT", "FT%"), + ("FG3_PCT", "3P%"), +] + +# Extra columns to show alongside RANK, PLAYER, TEAM, GP for each category +_EXTRA_COLS = { + "PTS": ["PTS", "FGM", "FGA", "FG_PCT", "FTM", "FTA", "FT_PCT"], + "REB": ["REB", "OREB", "DREB", "GP"], + "AST": ["AST", "TOV", "AST_TOV", "GP"], + "STL": ["STL", "TOV", "GP"], + "BLK": ["BLK", "PF", "GP"], + "EFF": ["EFF", "PTS", "REB", "AST", "GP"], + "FG_PCT": ["FG_PCT", "FGM", "FGA", "PTS"], + "FT_PCT": ["FT_PCT", "FTM", "FTA", "PTS"], + "FG3_PCT":["FG3_PCT", "FG3M", "FG3A", "PTS"], +} + +_DISPLAY_NAMES = { + "FG_PCT": "FG%", "FT_PCT": "FT%", "FG3_PCT": "3P%", + "FG3M": "3PM", "FG3A": "3PA", + "AST_TOV": "AST/TO", +} + + +def fetch_leaders(category: str = "PTS") -> dict: + endpoint = LeagueLeaders( + stat_category_abbreviation=category, + season_type_all_star="Regular Season", + ) + return json.loads(endpoint.get_json()) + + +def get_leaders_table(data: dict, category: str = "PTS") -> str: + result = data["resultSet"] + headers = result["headers"] + rows = result["rowSet"] + + base = ["RANK", "PLAYER", "TEAM", "GP"] + extra = [c for c in _EXTRA_COLS.get(category, [category]) if c not in base] + wanted = base + extra + + idx = {h: i for i, h in enumerate(headers)} + table_data = [ + [row[idx[col]] for col in wanted if col in idx] + for row in rows[:25] + ] + display_headers = [_DISPLAY_NAMES.get(c, c) for c in wanted if c in idx] + + cat_label = dict(CATEGORIES).get(category, category) + title = f"{BOLD}League Leaders — {cat_label}{END}" + return title + "\n" + tabulate(table_data, headers=display_headers, tablefmt="grid") diff --git a/nba/playoff.py b/nba/playoff.py new file mode 100644 index 0000000..eb3b12b --- /dev/null +++ b/nba/playoff.py @@ -0,0 +1,80 @@ +""" +Fetches and formats the NBA playoff picture. +""" + +import json + +from tabulate import tabulate +from nba_api.stats.endpoints.playoffpicture import PlayoffPicture + +BOLD = "\033[1m" +END = "\033[0m" +RED = "\033[91m" +GREEN = "\033[32m" +YELLOW = "\033[33m" + + +def fetch_playoff_picture() -> dict: + endpoint = PlayoffPicture() + return json.loads(endpoint.get_json()) + + +def _clinch_status(row: list, idx: dict) -> str: + """Return a color-coded status string from clinch/elimination columns.""" + def val(col): + return row[idx[col]] if col in idx else None + + if val("CLINCHED_CONFERENCE"): + return f"{BOLD}{GREEN}z-Clinched Conf{END}" + if val("CLINCHED_DIVISION") or val("CLINCHED_PLAYOFFS"): + return f"{GREEN}x-Clinched{END}" + if val("Clinched_Play_In"): + return f"{YELLOW}pi-Play-In{END}" + if val("ELIMINATED_PLAYOFFS"): + return f"{RED}e-Eliminated{END}" + return "" + + +def _build_conference_table(result_sets: list, name: str) -> str: + """Build a formatted playoff standings table for one conference.""" + rs = next((r for r in result_sets if r["name"] == name), None) + if rs is None or not rs["rowSet"]: + return "No data available." + + headers = rs["headers"] + rows = rs["rowSet"] + idx = {h: i for i, h in enumerate(headers)} + + def get(row, col, default=""): + return row[idx[col]] if col in idx else default + + table_data = [] + for row in rows: + wins = get(row, "WINS") + losses = get(row, "LOSSES") + pct = get(row, "PCT") + pct_str = f"{float(pct):.3f}" if pct not in ("", None) else "" + table_data.append([ + get(row, "RANK"), + get(row, "TEAM"), + f"{wins}-{losses}", + pct_str, + get(row, "GB"), + get(row, "HOME"), + get(row, "AWAY"), + get(row, "CONF"), + _clinch_status(row, idx), + ]) + + display_headers = ["#", "Team", "W-L", "PCT", "GB", "HOME", "AWAY", "CONF", "Status"] + conf_label = "Eastern" if "East" in name else "Western" + title = f"{BOLD}{conf_label} Conference Playoff Picture:{END}" + return title + "\n" + tabulate(table_data, headers=display_headers, tablefmt="grid") + + +def get_west_playoff_table(data: dict) -> str: + return _build_conference_table(data["resultSets"], "WestConfStandings") + + +def get_east_playoff_table(data: dict) -> str: + return _build_conference_table(data["resultSets"], "EastConfStandings") diff --git a/nba/scores.py b/nba/scores.py index 47ed552..28e4e82 100644 --- a/nba/scores.py +++ b/nba/scores.py @@ -31,20 +31,26 @@ def get_team_record(team_name, standings) -> str: return "N/A" -def build_scoreboard(games, standings) -> None: +def get_scoreboard_table(games, standings) -> str: """ - Prints the current day's games in a table format. + Builds and returns the current day's games as a formatted table string. Args: games (dict): JSON parsed games data. standings (dict): Team standings data. + + Returns: + str: Formatted table string with ANSI color codes. """ scoreboard_data = games["scoreboard"] - games = scoreboard_data["games"] + game_list = scoreboard_data["games"] + + if not game_list: + return "No games scheduled today." # Prepare the table data table_data = [] - for game in games: + for game in game_list: home_team = game["homeTeam"]["teamName"] away_team = game["awayTeam"]["teamName"] game_status = game["gameStatusText"] @@ -86,5 +92,15 @@ def build_scoreboard(games, standings) -> None: # Define the table headers headers = ["Team", "Score", "Game Status"] - # Print the table - print(tabulate(table_data, headers=headers, tablefmt="grid")) + return tabulate(table_data, headers=headers, tablefmt="grid") + + +def build_scoreboard(games, standings) -> None: + """ + Prints the current day's games in a table format. + + Args: + games (dict): JSON parsed games data. + standings (dict): Team standings data. + """ + print(get_scoreboard_table(games, standings)) diff --git a/nba/standings.py b/nba/standings.py index 9b6a56e..3abb1c0 100644 --- a/nba/standings.py +++ b/nba/standings.py @@ -11,73 +11,76 @@ RED = "\033[91m" GREEN = "\033[32m" -def build_standings(standings) -> None: - """ - Prints team standings in two separate tables. - - Args: - standings (dict): Team standings data. - """ - eastern_data = [] - western_data = [] - eastern_rank = 1 - western_rank = 1 +def _build_conference_table(standings, conference: str) -> str: + """Build a formatted table string for one conference.""" + data = [] + rank = 1 for result_set in standings["resultSets"]: if result_set["name"] == "Standings": for team in result_set["rowSet"]: - conference = team[5] - team_name = team[4] + if team[5] != conference: + continue wins = team[12] losses = team[13] win_pct = team[14] - gb = team[37] - home_record = team[17] - away_record = team[18] - last_10 = team[19] streak = team[35] - if int(streak) < 0: - strk_color = f"{RED}{streak}{END}" - else: - strk_color = f"{GREEN}{streak}{END}" - - if conference == "East": - eastern_data.append( - [ - f"{eastern_rank}", - f"{team_name}", - f"{wins}-{losses}", - f"{win_pct:.3f}", - f"{gb}", - f"{strk_color}", - f"{last_10}", - f"{home_record}", - f"{away_record}", - ] - ) - eastern_rank += 1 - elif conference == "West": - western_data.append( - [ - f"{western_rank}", - f"{team_name}", - f"{wins}-{losses}", - f"{win_pct:.3f}", - f"{gb}", - f"{strk_color}", - f"{last_10}", - f"{home_record}", - f"{away_record}", - ] - ) - western_rank += 1 + strk_color = ( + f"{RED}{streak}{END}" if int(streak) < 0 else f"{GREEN}{streak}{END}" + ) + + data.append( + [ + f"{rank}", + team[4], + f"{wins}-{losses}", + f"{win_pct:.3f}", + team[37], + strk_color, + team[19], + team[17], + team[18], + ] + ) + rank += 1 headers = ["Rank", "Team", "W-L", "PCT", "GB", "STRK", "L10", "HOME", "AWAY"] + label = "Eastern" if conference == "East" else "Western" + return ( + f"{BOLD}{label} Conference Standings:{END}\n" + + tabulate(data, headers=headers, tablefmt="grid") + ) + + +def get_east_standings_table(standings) -> str: + """Returns the Eastern Conference standings as a formatted string.""" + return _build_conference_table(standings, "East") + - print(f"{BOLD}Eastern Conference Standings:{END}") - print(tabulate(eastern_data, headers=headers, tablefmt="grid")) +def get_west_standings_table(standings) -> str: + """Returns the Western Conference standings as a formatted string.""" + return _build_conference_table(standings, "West") + + +def get_standings_tables(standings) -> str: + """ + Builds and returns both conference standings as a formatted string. - print("\n") - print(f"{BOLD}Western Conference Standings:{END}") - print(tabulate(western_data, headers=headers, tablefmt="grid")) + Args: + standings (dict): Team standings data. + + Returns: + str: Formatted standings string with ANSI color codes for both conferences. + """ + return get_east_standings_table(standings) + "\n\n" + get_west_standings_table(standings) + + +def build_standings(standings) -> None: + """ + Prints team standings in two separate tables. + + Args: + standings (dict): Team standings data. + """ + print(get_standings_tables(standings)) diff --git a/nba/tui/__init__.py b/nba/tui/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/nba/tui/__init__.py diff --git a/nba/tui/__pycache__/__init__.cpython-313.pyc b/nba/tui/__pycache__/__init__.cpython-313.pyc Binary files differnew file mode 100644 index 0000000..5a335f2 --- /dev/null +++ b/nba/tui/__pycache__/__init__.cpython-313.pyc diff --git a/nba/tui/__pycache__/__init__.cpython-314.pyc b/nba/tui/__pycache__/__init__.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..d1753cc --- /dev/null +++ b/nba/tui/__pycache__/__init__.cpython-314.pyc diff --git a/nba/tui/__pycache__/app.cpython-313.pyc b/nba/tui/__pycache__/app.cpython-313.pyc Binary files differnew file mode 100644 index 0000000..9e182f2 --- /dev/null +++ b/nba/tui/__pycache__/app.cpython-313.pyc diff --git a/nba/tui/__pycache__/app.cpython-314.pyc b/nba/tui/__pycache__/app.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..c6713a7 --- /dev/null +++ b/nba/tui/__pycache__/app.cpython-314.pyc diff --git a/nba/tui/__pycache__/widgets.cpython-313.pyc b/nba/tui/__pycache__/widgets.cpython-313.pyc Binary files differnew file mode 100644 index 0000000..2b9cfc1 --- /dev/null +++ b/nba/tui/__pycache__/widgets.cpython-313.pyc diff --git a/nba/tui/app.py b/nba/tui/app.py new file mode 100644 index 0000000..bfdec49 --- /dev/null +++ b/nba/tui/app.py @@ -0,0 +1,221 @@ +""" +Textual TUI application for NBA scores and standings. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from rich.text import Text +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.widgets import Footer, Header, Static, TabbedContent, TabPane + +from nba import fetch_data +from nba import leaders as leaders_mod +from nba import playoff as playoff_mod +from nba import box_score as box_score_mod +from nba.scores import get_scoreboard_table +from nba.standings import get_east_standings_table, get_west_standings_table +from nba.tui.widgets import CountdownBar, ScoresWidget + + +class NBAApp(App): + """Live NBA scores and standings TUI with auto-refresh.""" + + CSS_PATH = Path(__file__).parent / "styles.tcss" + + BINDINGS = [ + ("q", "quit", "Quit"), + ("s", "show_scores", "Scores"), + ("t", "show_standings", "Standings"), + ("l", "show_leaders", "Leaders"), + ("p", "show_playoff", "Playoff"), + ("b", "show_boxscore", "Box Score"), + ("r", "refresh_now", "Refresh"), + ("comma", "prev_category", "◀ Cat"), + ("full_stop", "next_category", "Cat ▶"), + ] + + TITLE = "NBA Scores" + + def __init__(self, initial_tab: str = "scores", refresh_interval: int = 60) -> None: + super().__init__() + self.initial_tab = initial_tab + self.refresh_interval = refresh_interval + self._games: dict | None = None + self._ranks: dict | None = None + self._leaders_data: dict | None = None + self._leaders_cat_idx: int = 0 + self._playoff_data: dict | None = None + + def compose(self) -> ComposeResult: + yield Header() + with TabbedContent(initial=self.initial_tab): + with TabPane("Scores", id="scores"): + yield ScoresWidget("Loading...", id="scores-content") + with TabPane("Standings", id="standings"): + with Horizontal(id="standings-container"): + yield Static("Loading...", id="west-content") + yield Static("Loading...", id="east-content") + with TabPane("Leaders", id="leaders"): + yield Static("Loading...", id="leaders-content") + with TabPane("Playoff", id="playoff"): + with Horizontal(id="playoff-container"): + yield Static("Loading...", id="playoff-west-content") + yield Static("Loading...", id="playoff-east-content") + with TabPane("Box Score", id="boxscore"): + with Horizontal(id="boxscore-container"): + yield Static( + "Press 1–9 to load a game from the Scores tab.", + id="home-content", + ) + yield Static("", id="away-content") + yield CountdownBar(self.refresh_interval, id="countdown") + yield Footer() + + async def on_mount(self) -> None: + await self._do_refresh() + self.set_interval(self.refresh_interval, self._do_refresh) + + # ------------------------------------------------------------------ # + # Refresh logic # + # ------------------------------------------------------------------ # + + async def _do_refresh(self) -> None: + """Fetch scores, standings, leaders, and playoff picture in parallel.""" + loop = asyncio.get_event_loop() + cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0] + + results = await asyncio.gather( + loop.run_in_executor(None, fetch_data.fetch_data), + loop.run_in_executor(None, lambda: leaders_mod.fetch_leaders(cat)), + loop.run_in_executor(None, playoff_mod.fetch_playoff_picture), + return_exceptions=True, + ) + + games_ranks, leaders_data, playoff_data = results + + if not isinstance(games_ranks, Exception): + self._games, self._ranks = games_ranks + if not isinstance(leaders_data, Exception): + self._leaders_data = leaders_data + if not isinstance(playoff_data, Exception): + self._playoff_data = playoff_data + + self._update_widgets() + self.query_one(CountdownBar).reset(self.refresh_interval) + + def _update_widgets(self) -> None: + if self._games and self._ranks: + self.query_one(ScoresWidget).update( + Text.from_ansi(get_scoreboard_table(self._games, self._ranks)) + ) + self.query_one("#west-content", Static).update( + Text.from_ansi(get_west_standings_table(self._ranks)) + ) + self.query_one("#east-content", Static).update( + Text.from_ansi(get_east_standings_table(self._ranks)) + ) + + if self._leaders_data: + cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0] + self.query_one("#leaders-content", Static).update( + Text.from_ansi(leaders_mod.get_leaders_table(self._leaders_data, cat)) + ) + + if self._playoff_data: + self.query_one("#playoff-west-content", Static).update( + Text.from_ansi(playoff_mod.get_west_playoff_table(self._playoff_data)) + ) + self.query_one("#playoff-east-content", Static).update( + Text.from_ansi(playoff_mod.get_east_playoff_table(self._playoff_data)) + ) + + # ------------------------------------------------------------------ # + # Key handlers # + # ------------------------------------------------------------------ # + + def on_key(self, event) -> None: + """Handle 1–9 to select a game for the box score tab.""" + char = event.character + if char and char.isdigit() and char != "0": + asyncio.create_task(self._load_box_score(int(char) - 1)) + + # ------------------------------------------------------------------ # + # Actions # + # ------------------------------------------------------------------ # + + async def action_refresh_now(self) -> None: + await self._do_refresh() + + def action_show_scores(self) -> None: + self.query_one(TabbedContent).active = "scores" + + def action_show_standings(self) -> None: + self.query_one(TabbedContent).active = "standings" + + def action_show_leaders(self) -> None: + self.query_one(TabbedContent).active = "leaders" + + def action_show_playoff(self) -> None: + self.query_one(TabbedContent).active = "playoff" + + def action_show_boxscore(self) -> None: + self.query_one(TabbedContent).active = "boxscore" + + async def action_prev_category(self) -> None: + self._leaders_cat_idx = (self._leaders_cat_idx - 1) % len(leaders_mod.CATEGORIES) + await self._refresh_leaders() + + async def action_next_category(self) -> None: + self._leaders_cat_idx = (self._leaders_cat_idx + 1) % len(leaders_mod.CATEGORIES) + await self._refresh_leaders() + + # ------------------------------------------------------------------ # + # Helpers # + # ------------------------------------------------------------------ # + + async def _refresh_leaders(self) -> None: + cat, label = leaders_mod.CATEGORIES[self._leaders_cat_idx] + self.query_one("#leaders-content", Static).update(f"Loading {label} leaders...") + loop = asyncio.get_event_loop() + try: + data = await loop.run_in_executor(None, lambda: leaders_mod.fetch_leaders(cat)) + self._leaders_data = data + self.query_one("#leaders-content", Static).update( + Text.from_ansi(leaders_mod.get_leaders_table(data, cat)) + ) + except Exception as exc: + self.query_one("#leaders-content", Static).update(f"Error loading leaders: {exc}") + + async def _load_box_score(self, game_idx: int) -> None: + if not self._games: + return + games = self._games["scoreboard"]["games"] + if game_idx >= len(games): + return + + game = games[game_idx] + game_id = game["gameId"] + home = game["homeTeam"]["teamName"] + away = game["awayTeam"]["teamName"] + + self.query_one("#home-content", Static).update( + f"Loading box score: {away} @ {home}..." + ) + self.query_one("#away-content", Static).update("") + self.query_one(TabbedContent).active = "boxscore" + + loop = asyncio.get_event_loop() + try: + data = await loop.run_in_executor( + None, lambda: box_score_mod.fetch_box_score(game_id) + ) + home_table, away_table = box_score_mod.get_box_score_tables(data) + self.query_one("#home-content", Static).update(Text.from_ansi(home_table)) + self.query_one("#away-content", Static).update(Text.from_ansi(away_table)) + except Exception as exc: + self.query_one("#home-content", Static).update(f"Error loading box score: {exc}") + self.query_one("#away-content", Static).update("") diff --git a/nba/tui/styles.tcss b/nba/tui/styles.tcss new file mode 100644 index 0000000..816608c --- /dev/null +++ b/nba/tui/styles.tcss @@ -0,0 +1,58 @@ +Screen { + background: $background; +} + +TabbedContent { + height: 1fr; +} + +TabPane { + overflow: auto auto; + padding: 1 2; +} + +/* Scores tab */ +#scores-content { + width: auto; +} + +/* Standings & Playoff tabs — two columns side by side */ +#standings-container, +#playoff-container, +#boxscore-container { + height: 1fr; + width: 100%; +} + +#east-content, +#west-content, +#playoff-east-content, +#playoff-west-content { + width: 1fr; + overflow: auto auto; + padding-right: 2; +} + +/* Leaders tab */ +#leaders-content { + width: auto; + overflow: auto auto; +} + +/* Box Score tab */ +#home-content, +#away-content { + width: 1fr; + overflow: auto auto; + padding-right: 2; +} + +/* Countdown / status bar */ +#countdown { + dock: bottom; + height: 1; + background: $panel; + color: $text-muted; + content-align: right middle; + padding-right: 2; +} diff --git a/nba/tui/widgets.py b/nba/tui/widgets.py new file mode 100644 index 0000000..f5e8c1d --- /dev/null +++ b/nba/tui/widgets.py @@ -0,0 +1,50 @@ +""" +Custom Textual widgets for the NBA scores TUI. +""" + +from textual.reactive import reactive +from textual.widgets import Static + + +class ScoresWidget(Static): + """Displays the NBA scoreboard as a scrollable ANSI-formatted table.""" + + +class StandingsWidget(Static): + """Displays the NBA standings as a scrollable ANSI-formatted table.""" + + +class CountdownBar(Static): + """ + Docked status bar that counts down to the next auto-refresh. + + Maintains its own 1-second ticker and re-renders via a reactive attribute. + """ + + seconds: reactive[int] = reactive(60) + + def __init__(self, interval: int, **kwargs) -> None: + super().__init__(**kwargs) + self._interval = interval + self.seconds = interval + + def on_mount(self) -> None: + self.set_interval(1, self._tick) + + def _tick(self) -> None: + if self.seconds > 0: + self.seconds -= 1 + + def watch_seconds(self, value: int) -> None: + if value <= 0: + self.update("Refreshing...") + else: + self.update( + f"Next refresh in {value}s | " + "\\[1-9] Box Score | \\[</>] Leaders Cat | \\[r] Refresh | \\[q] Quit" + ) + + def reset(self, interval: int) -> None: + """Reset the countdown to the given interval.""" + self._interval = interval + self.seconds = interval |
