summaryrefslogtreecommitdiff
path: root/nba/tui
diff options
context:
space:
mode:
Diffstat (limited to 'nba/tui')
-rw-r--r--nba/tui/__init__.py0
-rw-r--r--nba/tui/__pycache__/__init__.cpython-313.pycbin0 -> 153 bytes
-rw-r--r--nba/tui/__pycache__/__init__.cpython-314.pycbin0 -> 155 bytes
-rw-r--r--nba/tui/__pycache__/app.cpython-313.pycbin0 -> 14244 bytes
-rw-r--r--nba/tui/__pycache__/app.cpython-314.pycbin0 -> 6721 bytes
-rw-r--r--nba/tui/__pycache__/widgets.cpython-313.pycbin0 -> 2793 bytes
-rw-r--r--nba/tui/app.py221
-rw-r--r--nba/tui/styles.tcss58
-rw-r--r--nba/tui/widgets.py50
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
new file mode 100644
index 0000000..5a335f2
--- /dev/null
+++ b/nba/tui/__pycache__/__init__.cpython-313.pyc
Binary files differ
diff --git a/nba/tui/__pycache__/__init__.cpython-314.pyc b/nba/tui/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000..d1753cc
--- /dev/null
+++ b/nba/tui/__pycache__/__init__.cpython-314.pyc
Binary files differ
diff --git a/nba/tui/__pycache__/app.cpython-313.pyc b/nba/tui/__pycache__/app.cpython-313.pyc
new file mode 100644
index 0000000..9e182f2
--- /dev/null
+++ b/nba/tui/__pycache__/app.cpython-313.pyc
Binary files differ
diff --git a/nba/tui/__pycache__/app.cpython-314.pyc b/nba/tui/__pycache__/app.cpython-314.pyc
new file mode 100644
index 0000000..c6713a7
--- /dev/null
+++ b/nba/tui/__pycache__/app.cpython-314.pyc
Binary files differ
diff --git a/nba/tui/__pycache__/widgets.cpython-313.pyc b/nba/tui/__pycache__/widgets.cpython-313.pyc
new file mode 100644
index 0000000..2b9cfc1
--- /dev/null
+++ b/nba/tui/__pycache__/widgets.cpython-313.pyc
Binary files differ
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