diff options
Diffstat (limited to 'nba/tui')
| -rw-r--r-- | nba/tui/__init__.py | 0 | ||||
| -rw-r--r-- | nba/tui/__pycache__/__init__.cpython-313.pyc | bin | 0 -> 153 bytes | |||
| -rw-r--r-- | nba/tui/__pycache__/__init__.cpython-314.pyc | bin | 0 -> 155 bytes | |||
| -rw-r--r-- | nba/tui/__pycache__/app.cpython-313.pyc | bin | 0 -> 14244 bytes | |||
| -rw-r--r-- | nba/tui/__pycache__/app.cpython-314.pyc | bin | 0 -> 6721 bytes | |||
| -rw-r--r-- | nba/tui/__pycache__/widgets.cpython-313.pyc | bin | 0 -> 2793 bytes | |||
| -rw-r--r-- | nba/tui/app.py | 221 | ||||
| -rw-r--r-- | nba/tui/styles.tcss | 58 | ||||
| -rw-r--r-- | nba/tui/widgets.py | 50 |
9 files changed, 329 insertions, 0 deletions
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 |
