touchline 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hamed
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # Touchline
2
+
3
+ A live football match centre designed for the terminal. Touchline reads the same JSON feeds as the supplied SVT/eNetScores widget and turns them into a fast, keyboard-driven TUI.
4
+
5
+ ![Python](https://img.shields.io/badge/python-3.10%2B-c8f560?style=flat-square)
6
+ ![dependencies](https://img.shields.io/badge/runtime_dependencies-0-54e08b?style=flat-square)
7
+
8
+ ## Run it
9
+
10
+ No clone or installation is needed:
11
+
12
+ ```bash
13
+ npx touchline
14
+ ```
15
+
16
+ Touchline requires Node.js 18+ for `npx` and Python 3.10+ for the TUI runtime. It has no npm or Python package dependencies.
17
+
18
+ To run the source checkout directly:
19
+
20
+ ```bash
21
+ cd /Users/hamed/dev/claude-tools/touchline
22
+ PYTHONPATH=src python3 -m touchline
23
+ ```
24
+
25
+ The Spain–Argentina World Cup final is the default match. A different SVT iframe URL or direct eNetScores widget URL can be passed as the first argument:
26
+
27
+ ```bash
28
+ npx touchline 'https://widget.enetscores.com/CLIENT/ev/EVENT_ID/lng/sv'
29
+ ```
30
+
31
+ Commentary is translated to English by default, regardless of the language embedded in the source URL. Use `--language sv` to follow the original Swedish feed instead.
32
+
33
+ ## Views and keys
34
+
35
+ | Key | Action |
36
+ | --- | --- |
37
+ | `1`–`8` | Overview, live feed, team stats, lineups, formation, player stats, H2H, bracket |
38
+ | `←` / `→` or `h` / `l` | Previous/next view |
39
+ | `j` / `k` | Scroll |
40
+ | `r` | Refresh immediately |
41
+ | `q` | Quit |
42
+
43
+ The live score, commentary, team stats and player stats refresh every 10 seconds. Lineups, formations, H2H and the bracket are fetched once on startup because they rarely change. The feed itself is cached upstream for five seconds, so `--refresh 5` is the fastest useful setting.
44
+
45
+ ## Preview and diagnostics
46
+
47
+ Render a non-interactive frame without hitting the live feed:
48
+
49
+ ```bash
50
+ npx touchline --demo --snapshot --no-color
51
+ ```
52
+
53
+ Useful options:
54
+
55
+ ```text
56
+ --refresh SECONDS polling interval, minimum 5 seconds
57
+ --language, -L CODE commentary language; English by default
58
+ --demo use built-in sample data
59
+ --snapshot render once and exit
60
+ --tab 1..8 choose the initial view
61
+ --width / --height set snapshot dimensions
62
+ --no-color plain-text snapshot output
63
+ ```
64
+
65
+ ## Data source
66
+
67
+ Touchline does not scrape rendered HTML. It discovers and consumes the public JSON requests made by the eNetScores widget. The client code, event id and language are parsed from the supplied URL; tournament and bracket ids are discovered from the match metadata. If the provider changes its private widget schema in the future, the parsers in `src/touchline/api.py` may need to be adjusted.
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { spawnSync } = require("node:child_process");
4
+ const path = require("node:path");
5
+
6
+ const packageRoot = path.resolve(__dirname, "..");
7
+ const sourceRoot = path.join(packageRoot, "src");
8
+
9
+ function pythonCandidates() {
10
+ const configured = process.env.TOUCHLINE_PYTHON;
11
+ if (configured) return [[configured, []]];
12
+ if (process.platform === "win32") {
13
+ return [["py", ["-3"]], ["python", []], ["python3", []]];
14
+ }
15
+ return [["python3", []], ["python", []]];
16
+ }
17
+
18
+ function findPython() {
19
+ for (const [command, prefix] of pythonCandidates()) {
20
+ const probe = spawnSync(
21
+ command,
22
+ [...prefix, "-c", "import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)"],
23
+ { stdio: "ignore" },
24
+ );
25
+ if (probe.status === 0) return [command, prefix];
26
+ }
27
+ return null;
28
+ }
29
+
30
+ const python = findPython();
31
+ if (!python) {
32
+ console.error("touchline needs Python 3.10 or newer, but no compatible Python was found.");
33
+ console.error("Install Python from https://python.org, or set TOUCHLINE_PYTHON to its path.");
34
+ process.exit(1);
35
+ }
36
+
37
+ const [command, prefix] = python;
38
+ const existingPath = process.env.PYTHONPATH;
39
+ const env = {
40
+ ...process.env,
41
+ PYTHONPATH: existingPath ? `${sourceRoot}${path.delimiter}${existingPath}` : sourceRoot,
42
+ };
43
+ const result = spawnSync(
44
+ command,
45
+ [...prefix, "-m", "touchline", ...process.argv.slice(2)],
46
+ { stdio: "inherit", env },
47
+ );
48
+
49
+ if (result.error) {
50
+ console.error(`touchline could not start: ${result.error.message}`);
51
+ process.exit(1);
52
+ }
53
+ if (result.signal) {
54
+ process.kill(process.pid, result.signal);
55
+ }
56
+ process.exit(result.status ?? 1);
57
+
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "touchline",
3
+ "version": "0.1.0",
4
+ "description": "A live football match centre for your terminal",
5
+ "license": "MIT",
6
+ "author": "Hamed",
7
+ "type": "commonjs",
8
+ "bin": {
9
+ "touchline": "bin/touchline.js"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "src/touchline/*.py",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "keywords": [
21
+ "football",
22
+ "soccer",
23
+ "live-score",
24
+ "terminal",
25
+ "tui",
26
+ "cli"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ }
31
+ }
@@ -0,0 +1,4 @@
1
+ """Touchline — a live football match centre for the terminal."""
2
+
3
+ __version__ = "0.1.0"
4
+
@@ -0,0 +1,5 @@
1
+ from .app import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
5
+
@@ -0,0 +1,357 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import urllib.parse
6
+ import urllib.request
7
+ from concurrent.futures import ThreadPoolExecutor, as_completed
8
+ from datetime import datetime
9
+ from typing import Any
10
+
11
+ from .model import Event, MatchState, Team
12
+
13
+ DEFAULT_IFRAME_URL = (
14
+ "https://api.svt.se/news/embed/script-iframe?"
15
+ "url=https%3A%2F%2Fwidget.enetscores.com%2FFW15C12BED468AE5A7%2F"
16
+ "ev%2F4653858%2Flng%2Fsv"
17
+ )
18
+ API_VERSION = "11.546"
19
+ THEME = "theme_webchunk_22"
20
+
21
+
22
+ class FeedError(RuntimeError):
23
+ pass
24
+
25
+
26
+ def parse_widget_url(value: str) -> tuple[str, str, str]:
27
+ """Return client code, event id and language from an iframe/widget URL."""
28
+ parsed = urllib.parse.urlparse(value)
29
+ query = urllib.parse.parse_qs(parsed.query)
30
+ inner = query.get("url", [value])[0]
31
+ decoded = urllib.parse.unquote(inner)
32
+ match = re.search(r"widget\.enetscores\.com/([^/]+)/ev/(\d+)/lng/([a-z-]+)", decoded)
33
+ if not match:
34
+ match = re.search(r"widget\.enetscores\.com/([^/]+)/(?:ev/)?(\d+)/(?:lng/)?([a-z-]+)?", decoded)
35
+ if not match:
36
+ raise FeedError("Could not find the eNetScores client and event id in that URL")
37
+ return match.group(1), match.group(2), match.group(3) or "sv"
38
+
39
+
40
+ def _decode_content(payload: dict[str, Any]) -> list[dict[str, Any]]:
41
+ content = payload.get("content", [])
42
+ if isinstance(content, str):
43
+ decoded = json.loads(content)
44
+ return [{"data": decoded, "fields": []}]
45
+ if isinstance(content, dict):
46
+ return [{"data": content, "fields": []}]
47
+ return content if isinstance(content, list) else []
48
+
49
+
50
+ class EnetClient:
51
+ def __init__(self, iframe_url: str = DEFAULT_IFRAME_URL, timeout: float = 8.0, language: str | None = "en"):
52
+ self.client_code, self.event_id, url_language = parse_widget_url(iframe_url)
53
+ self.language = language or url_language
54
+ if not re.fullmatch(r"[a-z]{2}(?:-[a-z]{2})?", self.language):
55
+ raise FeedError(f"Unsupported language code: {self.language}")
56
+ self.timeout = timeout
57
+ self.base = f"https://es-ds.enetscores.com/{API_VERSION}/{self.client_code}"
58
+ self.stage_id = ""
59
+ self.draw_id = ""
60
+
61
+ def _get(self, url: str) -> dict[str, Any]:
62
+ request = urllib.request.Request(
63
+ url,
64
+ headers={
65
+ "Accept": "application/json",
66
+ "User-Agent": "Touchline/0.1 (+terminal match centre)",
67
+ "Origin": "https://api.svt.se",
68
+ "Referer": "https://api.svt.se/",
69
+ },
70
+ )
71
+ try:
72
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
73
+ return json.load(response)
74
+ except Exception as exc:
75
+ raise FeedError(f"Live feed unavailable: {exc}") from exc
76
+
77
+ def _url(self, subject: str) -> str:
78
+ return f"{self.base}/{subject.lstrip('/')}"
79
+
80
+ def discover(self) -> dict[str, Any]:
81
+ subject = (
82
+ f"live-{self.language}-event_compound_top_component-1-{self.event_id}"
83
+ f"-t_g-{THEME}"
84
+ )
85
+ payload = self._get(self._url(subject))
86
+ blocks = _decode_content(payload)
87
+ data = blocks[0].get("data", {}) if blocks else {}
88
+ event = data.get("e", {})
89
+ self.stage_id = str(event.get("tsid", ""))
90
+ if self.stage_id:
91
+ full = (
92
+ f"live-{self.language}-event_compound-event_compound_top_component-1-{self.event_id}"
93
+ f"-t_g-{THEME}-event_compound-ms-standings-ts-{self.stage_id}"
94
+ f"-ms-draw-ts-{self.stage_id}"
95
+ )
96
+ full_blocks = _decode_content(self._get(self._url(full)))
97
+ if len(full_blocks) > 1:
98
+ self.draw_id = str(full_blocks[1].get("data", {}).get("draw", ""))
99
+ return data
100
+
101
+ def subjects(self) -> dict[str, str]:
102
+ e = self.event_id
103
+ lang = self.language
104
+ common = f"live-{lang}-event_compound"
105
+ result = {
106
+ "core": f"live-{lang}-event_compound_top_component-1-{e}-t_g-{THEME}",
107
+ "match": (
108
+ f"/{common}-event-incidents-1-{e}-t_g-id_type_event_info-{THEME}"
109
+ f"-icf_all-otid_0-event_compound-action_map-1-{e}-p0300-t_g-{THEME}"
110
+ f"-event_compound-momentum-1-{e}-t_g-{THEME}"
111
+ ),
112
+ "comments": f"/{common}-event-comments-1-{e}-t_g-cmd_type_comments-{THEME}",
113
+ "stats": f"{common}-event-livestats-1-{e}-t_g-lsd_type_event_stats-{THEME}-ls_dt_type_default",
114
+ "lineups": f"/{common}-event-lineups-1-{e}-t_g-{THEME}-ln_type_lineups-tm_tbs_yes",
115
+ "formation": f"/{common}-event-lineups-1-{e}-t_g-{THEME}-ln_type_formation_lineups",
116
+ "h2h": (
117
+ f"/{common}-event-h2h-1-{e}-t_g-h2hd_type_h2h-{THEME}"
118
+ "-otid_0-sel_tb_all-el_limit_5-ev_n_r_5-emb_yes"
119
+ ),
120
+ "players": (
121
+ f"{common}-extended_participants_stats-1--par_t_event-t_g-{THEME}"
122
+ f"-stat_cat_general--ev_{e}-fss_0"
123
+ ),
124
+ }
125
+ if self.draw_id:
126
+ result["bracket"] = f"/{common}-tournament_stage_draw-1-{self.draw_id}-t_g-{THEME}"
127
+ return result
128
+
129
+ def fetch(self, include_static: bool = True) -> dict[str, dict[str, Any]]:
130
+ if not self.stage_id:
131
+ core = self.discover()
132
+ else:
133
+ core = None
134
+ subjects = self.subjects()
135
+ dynamic = {"core", "match", "comments", "stats", "players"}
136
+ selected = subjects if include_static else {k: v for k, v in subjects.items() if k in dynamic}
137
+ result: dict[str, dict[str, Any]] = {}
138
+ if core is not None:
139
+ result["core"] = {"content": core}
140
+ selected.pop("core", None)
141
+ with ThreadPoolExecutor(max_workers=min(6, len(selected) or 1)) as pool:
142
+ futures = {pool.submit(self._get, self._url(subject)): name for name, subject in selected.items()}
143
+ for future in as_completed(futures):
144
+ name = futures[future]
145
+ result[name] = future.result()
146
+ return result
147
+
148
+
149
+ def _event_data(payload: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
150
+ blocks = _decode_content(payload)
151
+ if not blocks:
152
+ return {}, []
153
+ return blocks[0].get("data", {}), blocks[0].get("fields", [])
154
+
155
+
156
+ def _team(raw: dict[str, Any]) -> Team:
157
+ return Team(
158
+ id=str(raw.get("pi") or raw.get("ti") or ""),
159
+ name=str(raw.get("pn") or raw.get("tn") or "—"),
160
+ short=str(raw.get("pns") or raw.get("tns") or raw.get("pn") or "—"),
161
+ score=str(raw.get("frs") or raw.get("rs6") or raw.get("rs1") or "0"),
162
+ country=str(raw.get("cns") or raw.get("cn") or ""),
163
+ )
164
+
165
+
166
+ def _parse_core(state: MatchState, payload: dict[str, Any]) -> None:
167
+ if "content" in payload and isinstance(payload["content"], dict):
168
+ data = payload["content"]
169
+ else:
170
+ data, _ = _event_data(payload)
171
+ event = data.get("e", {})
172
+ participants = event.get("par", {})
173
+ if participants:
174
+ state.home = _team(participants.get("1", {}))
175
+ state.away = _team(participants.get("2", {}))
176
+ state.status = str(event.get("st", state.status))
177
+ state.period = str(event.get("sns") or event.get("sn") or state.period)
178
+ state.minute = str(event.get("et") or state.minute)
179
+ state.round_name = str(event.get("rnd") or state.round_name)
180
+ state.start_time = str(event.get("sd") or state.start_time)
181
+
182
+
183
+ def _parse_comments(state: MatchState, payload: dict[str, Any]) -> None:
184
+ data, fields = _event_data(payload)
185
+ tournament = data.get("t", {})
186
+ event = data.get("e", {})
187
+ venue = event.get("vn", {})
188
+ state.competition = str(tournament.get("nm") or tournament.get("nms") or state.competition)
189
+ state.venue = str(venue.get("nm") or venue.get("vnm") or state.venue)
190
+ state.city = str(venue.get("ct") or venue.get("cts") or state.city)
191
+ state.minute = str(event.get("et") or state.minute)
192
+ parsed: list[Event] = []
193
+ for field in fields:
194
+ if not str(field.get("name", "")).startswith("ei_"):
195
+ continue
196
+ try:
197
+ item = json.loads(field.get("value", "{}"))
198
+ except (TypeError, json.JSONDecodeError):
199
+ continue
200
+ parsed.append(Event(
201
+ minute=str(item.get("et", "")),
202
+ kind=str(item.get("tp", "info")),
203
+ text=str(item.get("nm", "")),
204
+ team_id=str(item.get("ti", "")),
205
+ sequence=int(item.get("s", 0) or 0),
206
+ ))
207
+ state.events = sorted(parsed, key=lambda item: item.sequence, reverse=True)
208
+
209
+
210
+ def _parse_stats(state: MatchState, payload: dict[str, Any]) -> None:
211
+ data, _ = _event_data(payload)
212
+ stats = data.get("ls", {})
213
+ state.stats = {
214
+ key: {"home": str(value.get("1", "0")), "away": str(value.get("2", "0"))}
215
+ for key, value in stats.items()
216
+ if isinstance(value, dict) and not key.endswith("_541")
217
+ }
218
+
219
+
220
+ def _parse_match(state: MatchState, payload: dict[str, Any]) -> None:
221
+ blocks = _decode_content(payload)
222
+ if not blocks:
223
+ return
224
+ info = blocks[0].get("data", {})
225
+ event = info.get("e", {})
226
+ venue = info.get("vn", {})
227
+ tournament = info.get("t", {})
228
+ state.round_name = str(event.get("rnd") or state.round_name)
229
+ state.venue = str(venue.get("nm") or state.venue)
230
+ state.city = str(venue.get("ct") or state.city)
231
+ state.competition = str(tournament.get("nm") or state.competition)
232
+ if len(blocks) > 1:
233
+ fields = {field.get("name"): field.get("value") for field in blocks[1].get("fields", [])}
234
+ try:
235
+ actions = json.loads(fields.get("a", "{}"))
236
+ except (TypeError, json.JSONDecodeError):
237
+ actions = {}
238
+ state.pitch_actions = [
239
+ action for action in actions.values()
240
+ if action.get("coords_set") and action.get("coords_x") not in (None, "")
241
+ ]
242
+ if len(blocks) > 2:
243
+ momentum_data = blocks[2].get("data", {}).get("data", {})
244
+ momentum_event = momentum_data.get("e", {})
245
+ if momentum_event.get("et") not in (None, ""):
246
+ state.minute = str(momentum_event["et"])
247
+ incident_values = momentum_data.get("incidents", {}).values()
248
+ points: list[tuple[int, float]] = []
249
+ for item in incident_values:
250
+ incident = item.get("incident_data", {})
251
+ try:
252
+ minute = int(incident.get("elapsed", 0))
253
+ value = float(incident.get("home_pt", 0)) - float(incident.get("away_pt", 0))
254
+ except (TypeError, ValueError):
255
+ continue
256
+ points.append((minute, value))
257
+ if points:
258
+ try:
259
+ maximum = int(momentum_event.get("et", ""))
260
+ except (TypeError, ValueError):
261
+ maximum = max(minute for minute, _ in points)
262
+ state.momentum = [0.0] * (maximum + 1)
263
+ for minute, value in points:
264
+ if minute <= maximum:
265
+ state.momentum[minute] = value
266
+
267
+
268
+ def _parse_lineups(state: MatchState, payload: dict[str, Any]) -> None:
269
+ data, _ = _event_data(payload)
270
+ groups = data.get("lnp", {})
271
+ parsed: dict[str, dict[str, list[dict[str, Any]]]] = {}
272
+ for key, group in groups.items():
273
+ listing = group.get("list", {}) if isinstance(group, dict) else {}
274
+ parsed[key] = {
275
+ "home": listing.get("home", []),
276
+ "away": listing.get("away", []),
277
+ }
278
+ state.lineups = parsed
279
+
280
+
281
+ def _parse_formation(state: MatchState, payload: dict[str, Any]) -> None:
282
+ data, _ = _event_data(payload)
283
+ participants = data.get("e", {}).get("par", {})
284
+ state.formations = {
285
+ "home": str(participants.get("1", {}).get("formation", "")),
286
+ "away": str(participants.get("2", {}).get("formation", "")),
287
+ }
288
+ if not state.lineups:
289
+ _parse_lineups(state, payload)
290
+
291
+
292
+ def _event_row(item: dict[str, Any]) -> dict[str, Any]:
293
+ par = item.get("par", {})
294
+ home, away = par.get("1", {}), par.get("2", {})
295
+ return {
296
+ "date": str(item.get("sd", ""))[:10],
297
+ "home": home.get("pn", "—"),
298
+ "away": away.get("pn", "—"),
299
+ "home_score": home.get("frs", home.get("rs6", "–")),
300
+ "away_score": away.get("frs", away.get("rs6", "–")),
301
+ "competition": item.get("ttn", ""),
302
+ }
303
+
304
+
305
+ def _parse_h2h(state: MatchState, payload: dict[str, Any]) -> None:
306
+ data, _ = _event_data(payload)
307
+ sections = data.get("data", {})
308
+ state.h2h = {}
309
+ for name in ("h2h", "home", "away"):
310
+ events = sections.get(name, {}).get("e", {})
311
+ state.h2h[name] = [_event_row(item) for item in events.values()]
312
+
313
+
314
+ def _parse_players(state: MatchState, payload: dict[str, Any]) -> None:
315
+ data, _ = _event_data(payload)
316
+ raw = data.get("data", [])
317
+ if isinstance(raw, dict):
318
+ raw = list(raw.values())
319
+ state.player_stats = raw if isinstance(raw, list) else []
320
+
321
+
322
+ def _parse_bracket(state: MatchState, payload: dict[str, Any]) -> None:
323
+ outer, _ = _event_data(payload)
324
+ draw = outer.get("data", {}).get("dr", {})
325
+ rounds = draw.get("dre", {})
326
+ parsed: dict[str, list[dict[str, Any]]] = {}
327
+ for round_name, round_data in rounds.items():
328
+ matches: list[dict[str, Any]] = []
329
+ for pair in round_data.get("pairs", {}).values():
330
+ for item in pair.get("events", {}).values():
331
+ matches.append(_event_row(item))
332
+ parsed[round_name] = matches
333
+ state.bracket = parsed
334
+
335
+
336
+ PARSERS = {
337
+ "core": _parse_core,
338
+ "match": _parse_match,
339
+ "comments": _parse_comments,
340
+ "stats": _parse_stats,
341
+ "lineups": _parse_lineups,
342
+ "formation": _parse_formation,
343
+ "h2h": _parse_h2h,
344
+ "players": _parse_players,
345
+ "bracket": _parse_bracket,
346
+ }
347
+
348
+
349
+ def merge_payloads(state: MatchState, payloads: dict[str, dict[str, Any]]) -> MatchState:
350
+ for name, payload in payloads.items():
351
+ parser = PARSERS.get(name)
352
+ if parser:
353
+ parser(state, payload)
354
+ state.updated_at = datetime.now()
355
+ state.loading = False
356
+ state.error = ""
357
+ return state
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import queue
6
+ import select
7
+ import shutil
8
+ import sys
9
+ import termios
10
+ import threading
11
+ import time
12
+ import tty
13
+ from dataclasses import dataclass
14
+
15
+ from .api import DEFAULT_IFRAME_URL, EnetClient, FeedError, merge_payloads
16
+ from .demo import demo_state
17
+ from .model import MatchState
18
+ from .ui import Renderer, TABS, strip_ansi
19
+
20
+
21
+ @dataclass
22
+ class AppState:
23
+ tab: int = 0
24
+ scroll: int = 0
25
+ running: bool = True
26
+ force_refresh: bool = False
27
+
28
+
29
+ class TouchlineApp:
30
+ def __init__(self, url: str, refresh: float = 10.0, demo: bool = False, language: str = "en"):
31
+ self.demo = demo
32
+ self.refresh_interval = max(5.0, refresh)
33
+ self.ui = AppState()
34
+ self.match = demo_state() if demo else MatchState(event_id="")
35
+ self.client = None if demo else EnetClient(url, language=language)
36
+ if self.client:
37
+ self.match.event_id = self.client.event_id
38
+ self.results: queue.Queue[tuple[dict | None, Exception | None]] = queue.Queue()
39
+ self.fetching = False
40
+ self.loaded_static = False
41
+ self.last_fetch = 0.0
42
+
43
+ def _fetch(self, include_static: bool) -> None:
44
+ try:
45
+ assert self.client is not None
46
+ self.results.put((self.client.fetch(include_static=include_static), None))
47
+ except Exception as exc:
48
+ self.results.put((None, exc))
49
+
50
+ def request_refresh(self, force: bool = False) -> None:
51
+ if self.demo or self.fetching:
52
+ return
53
+ if not force and time.monotonic() - self.last_fetch < self.refresh_interval:
54
+ return
55
+ self.fetching = True
56
+ thread = threading.Thread(target=self._fetch, args=(not self.loaded_static,), daemon=True)
57
+ thread.start()
58
+
59
+ def collect(self) -> bool:
60
+ changed = False
61
+ while True:
62
+ try:
63
+ payloads, error = self.results.get_nowait()
64
+ except queue.Empty:
65
+ break
66
+ self.fetching = False
67
+ self.last_fetch = time.monotonic()
68
+ if error:
69
+ self.match.error = str(error).replace("Live feed unavailable: ", "")
70
+ self.match.loading = False
71
+ elif payloads is not None:
72
+ merge_payloads(self.match, payloads)
73
+ self.loaded_static = True
74
+ changed = True
75
+ return changed
76
+
77
+ def frame(self, width: int, height: int) -> str:
78
+ return Renderer(self.match, self.ui.tab, self.ui.scroll, self.demo).render(width, height)
79
+
80
+ def handle_key(self, key: str) -> None:
81
+ if key in ("q", "Q", "\x03"):
82
+ self.ui.running = False
83
+ elif key in ("\x1b[C", "l", "L", "\t"):
84
+ self.ui.tab = (self.ui.tab + 1) % len(TABS)
85
+ self.ui.scroll = 0
86
+ elif key in ("\x1b[D", "h", "H", "\x1b[Z"):
87
+ self.ui.tab = (self.ui.tab - 1) % len(TABS)
88
+ self.ui.scroll = 0
89
+ elif key in ("j", "J", "\x1b[B"):
90
+ self.ui.scroll += 1
91
+ elif key in ("k", "K", "\x1b[A"):
92
+ self.ui.scroll = max(0, self.ui.scroll - 1)
93
+ elif key in ("r", "R"):
94
+ self.last_fetch = 0
95
+ self.request_refresh(force=True)
96
+ elif key.isdigit() and 1 <= int(key) <= len(TABS):
97
+ self.ui.tab = int(key) - 1
98
+ self.ui.scroll = 0
99
+
100
+ def run(self) -> None:
101
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
102
+ raise SystemExit("Interactive mode needs a TTY. Try --snapshot for a static frame.")
103
+ previous = termios.tcgetattr(sys.stdin)
104
+ try:
105
+ tty.setcbreak(sys.stdin.fileno())
106
+ sys.stdout.write("\x1b[?1049h\x1b[?25l")
107
+ self.request_refresh(force=True)
108
+ dirty = True
109
+ while self.ui.running:
110
+ self.request_refresh()
111
+ dirty = self.collect() or dirty
112
+ width, height = shutil.get_terminal_size((120, 36))
113
+ if dirty:
114
+ sys.stdout.write("\x1b[H" + self.frame(width, height))
115
+ sys.stdout.flush()
116
+ dirty = False
117
+ ready, _, _ = select.select([sys.stdin], [], [], 0.15)
118
+ if ready:
119
+ key = os.read(sys.stdin.fileno(), 8).decode(errors="ignore")
120
+ self.handle_key(key)
121
+ dirty = True
122
+ finally:
123
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, previous)
124
+ sys.stdout.write("\x1b[?25h\x1b[?1049l")
125
+ sys.stdout.flush()
126
+
127
+
128
+ def build_parser() -> argparse.ArgumentParser:
129
+ parser = argparse.ArgumentParser(description="Follow a live football match from your terminal")
130
+ parser.add_argument("url", nargs="?", default=DEFAULT_IFRAME_URL, help="SVT iframe or eNetScores widget URL")
131
+ parser.add_argument("--refresh", type=float, default=10.0, metavar="SECONDS", help="refresh interval (minimum 5)")
132
+ parser.add_argument("--language", "-L", default="en", help="commentary language (default: en; try sv, es, de)")
133
+ parser.add_argument("--demo", action="store_true", help="use polished sample data without a network connection")
134
+ parser.add_argument("--snapshot", action="store_true", help="print one frame and exit")
135
+ parser.add_argument("--width", type=int, default=120, help="snapshot width")
136
+ parser.add_argument("--height", type=int, default=36, help="snapshot height")
137
+ parser.add_argument("--tab", type=int, choices=range(1, 9), default=1, help="initial view, 1–8")
138
+ parser.add_argument("--no-color", action="store_true", help="strip ANSI color from snapshots")
139
+ return parser
140
+
141
+
142
+ def main() -> None:
143
+ args = build_parser().parse_args()
144
+ try:
145
+ app = TouchlineApp(args.url, args.refresh, args.demo, args.language)
146
+ except FeedError as exc:
147
+ raise SystemExit(f"touchline: {exc}") from exc
148
+ app.ui.tab = args.tab - 1
149
+ if args.snapshot:
150
+ if not args.demo:
151
+ try:
152
+ assert app.client is not None
153
+ merge_payloads(app.match, app.client.fetch(include_static=True))
154
+ except Exception as exc:
155
+ app.match.loading = False
156
+ app.match.error = str(exc)
157
+ frame = app.frame(args.width, args.height)
158
+ print(strip_ansi(frame) if args.no_color else frame, end="")
159
+ return
160
+ app.run()
161
+
162
+
163
+ if __name__ == "__main__":
164
+ main()
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from .model import Event, MatchState, Team
6
+
7
+
8
+ def demo_state() -> MatchState:
9
+ state = MatchState(event_id="4653858")
10
+ state.home = Team("6720", "Spain", "ESP", "2", "ESP")
11
+ state.away = Team("6706", "Argentina", "ARG", "1", "ARG")
12
+ state.status = "inprogress"
13
+ state.period = "2nd half"
14
+ state.minute = "67"
15
+ state.competition = "World Cup 2026"
16
+ state.round_name = "Final"
17
+ state.venue = "New York/New Jersey Stadium"
18
+ state.city = "East Rutherford"
19
+ state.events = [
20
+ Event("67", "shoton", "Lamine Yamal bends a shot toward the far corner. Martinez saves.", "6720", 14),
21
+ Event("63", "subst", "Argentina: Lautaro Martinez replaces Nicolas Gonzalez.", "6706", 13),
22
+ Event("58", "goal", "GOAL Spain — Dani Olmo finds the bottom-left corner.", "6720", 12),
23
+ Event("53", "yellowcard", "Cristian Romero is booked for a late challenge.", "6706", 11),
24
+ Event("46", "2h", "The second half is underway.", "", 10),
25
+ Event("41", "goal", "GOAL Argentina — Lionel Messi converts from the spot.", "6706", 9),
26
+ Event("24", "goal", "GOAL Spain — Mikel Oyarzabal heads home.", "6720", 8),
27
+ ]
28
+ state.stats = {
29
+ "expected_goal": {"home": "1.82", "away": "1.10"},
30
+ "possession": {"home": "57", "away": "43"},
31
+ "goal_attempt": {"home": "13", "away": "8"},
32
+ "shoton": {"home": "6", "away": "3"},
33
+ "shotoff": {"home": "4", "away": "3"},
34
+ "corner": {"home": "5", "away": "2"},
35
+ "foulcommit": {"home": "8", "away": "11"},
36
+ "offside": {"home": "1", "away": "2"},
37
+ "yellow_cards": {"home": "1", "away": "2"},
38
+ "red_cards": {"home": "0", "away": "0"},
39
+ }
40
+ state.momentum = [0, 1, -1, 0, 2, 3, 1, -2, -1, 2, 4, 2, 1, -3, -2, 1, 3, 2, 4, 1]
41
+ state.pitch_actions = [
42
+ {"coords_x": "22", "coords_y": "35", "ti": "6720", "tp": "shoton"},
43
+ {"coords_x": "71", "coords_y": "64", "ti": "6706", "tp": "foulcommit"},
44
+ {"coords_x": "84", "coords_y": "48", "ti": "6720", "tp": "goal"},
45
+ {"coords_x": "44", "coords_y": "21", "ti": "6706", "tp": "cross"},
46
+ {"coords_x": "66", "coords_y": "38", "ti": "6720", "tp": "shoton"},
47
+ ]
48
+ home = [
49
+ ("23", "Unai Simon", 0), ("12", "Pedro Porro", 1), ("22", "Pau Cubarsi", 1),
50
+ ("14", "Aymeric Laporte", 1), ("24", "Marc Cucurella", 1), ("16", "Rodri", 2),
51
+ ("8", "Fabian Ruiz", 2), ("19", "Lamine Yamal", 2), ("10", "Dani Olmo", 2),
52
+ ("15", "Alex Baena", 2), ("21", "Mikel Oyarzabal", 3),
53
+ ]
54
+ away = [
55
+ ("23", "Emiliano Martinez", 0), ("4", "Gonzalo Montiel", 1), ("13", "Cristian Romero", 1),
56
+ ("6", "Lisandro Martinez", 1), ("3", "Nicolas Tagliafico", 1), ("7", "Rodrigo De Paul", 2),
57
+ ("24", "Enzo Fernandez", 2), ("20", "Alexis Mac Allister", 2), ("15", "Nicolas Gonzalez", 2),
58
+ ("10", "Lionel Messi", 3), ("9", "Julian Alvarez", 3),
59
+ ]
60
+ convert = lambda rows: [{"sn": n, "pn": name, "pns": name, "so": pos} for n, name, pos in rows]
61
+ state.lineups = {"lineups": {"home": convert(home), "away": convert(away)}, "bench": {"home": [], "away": []}}
62
+ state.formations = {"home": "4-2-3-1", "away": "4-4-2"}
63
+ state.h2h = {"h2h": [
64
+ {"date": "2018-03-27", "home": "Spain", "away": "Argentina", "home_score": "6", "away_score": "1", "competition": "Friendly"},
65
+ {"date": "2010-09-07", "home": "Argentina", "away": "Spain", "home_score": "4", "away_score": "1", "competition": "Friendly"},
66
+ {"date": "2009-11-14", "home": "Spain", "away": "Argentina", "home_score": "2", "away_score": "1", "competition": "Friendly"},
67
+ ], "home": [], "away": []}
68
+ state.bracket = {
69
+ "Quarter Finals": [
70
+ {"home": "France", "away": "Morocco", "home_score": "2", "away_score": "0", "date": "2026-07-09"},
71
+ {"home": "Spain", "away": "Belgium", "home_score": "2", "away_score": "1", "date": "2026-07-10"},
72
+ {"home": "Norway", "away": "England", "home_score": "1", "away_score": "2", "date": "2026-07-11"},
73
+ {"home": "Argentina", "away": "Switzerland", "home_score": "3", "away_score": "1", "date": "2026-07-12"},
74
+ ],
75
+ "Semi Finals": [
76
+ {"home": "France", "away": "Spain", "home_score": "0", "away_score": "2", "date": "2026-07-14"},
77
+ {"home": "England", "away": "Argentina", "home_score": "1", "away_score": "2", "date": "2026-07-15"},
78
+ ],
79
+ "Final": [{"home": "Spain", "away": "Argentina", "home_score": "2", "away_score": "1", "date": "LIVE"}],
80
+ }
81
+ state.updated_at = datetime.now()
82
+ state.loading = False
83
+ return state
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class Team:
10
+ id: str = ""
11
+ name: str = "—"
12
+ short: str = "—"
13
+ score: str = "0"
14
+ country: str = ""
15
+
16
+
17
+ @dataclass
18
+ class Event:
19
+ minute: str
20
+ kind: str
21
+ text: str
22
+ team_id: str = ""
23
+ sequence: int = 0
24
+
25
+
26
+ @dataclass
27
+ class MatchState:
28
+ event_id: str
29
+ home: Team = field(default_factory=Team)
30
+ away: Team = field(default_factory=Team)
31
+ status: str = "loading"
32
+ period: str = ""
33
+ minute: str = "—"
34
+ competition: str = "World Cup"
35
+ round_name: str = "Final"
36
+ venue: str = ""
37
+ city: str = ""
38
+ start_time: str = ""
39
+ events: list[Event] = field(default_factory=list)
40
+ stats: dict[str, dict[str, str]] = field(default_factory=dict)
41
+ lineups: dict[str, dict[str, list[dict[str, Any]]]] = field(default_factory=dict)
42
+ formations: dict[str, str] = field(default_factory=dict)
43
+ player_stats: list[dict[str, Any]] = field(default_factory=list)
44
+ h2h: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
45
+ bracket: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
46
+ pitch_actions: list[dict[str, Any]] = field(default_factory=list)
47
+ momentum: list[float] = field(default_factory=list)
48
+ updated_at: datetime | None = None
49
+ error: str = ""
50
+ loading: bool = True
51
+
52
+ @property
53
+ def is_live(self) -> bool:
54
+ return self.status == "inprogress"
55
+
56
+
57
+ STAT_LABELS = {
58
+ "expected_goal": "Expected goals",
59
+ "possession": "Possession",
60
+ "shoton": "Shots on target",
61
+ "shotoff": "Shots off target",
62
+ "goal_attempt": "Goal attempts",
63
+ "blocked_shots": "Blocked shots",
64
+ "corner": "Corners",
65
+ "cross": "Crosses",
66
+ "throwin": "Throw-ins",
67
+ "offside": "Offsides",
68
+ "goalkick": "Goal kicks",
69
+ "freekick": "Free kicks",
70
+ "foulcommit": "Fouls",
71
+ "yellow_cards": "Yellow cards",
72
+ "red_cards": "Red cards",
73
+ "saves": "Saves",
74
+ "treatment": "Treatments",
75
+ "subst": "Substitutions",
76
+ }
77
+
78
+ EVENT_ICONS = {
79
+ "goal": "GOAL",
80
+ "yellowcard": "CARD",
81
+ "redcard": "RED",
82
+ "subst": "SUB",
83
+ "kickoff": "KICK",
84
+ "corner": "CORNER",
85
+ "shoton": "SHOT",
86
+ "shotoff": "MISS",
87
+ "foulcommit": "FOUL",
88
+ "offside": "OFF",
89
+ "fieldpossession": "PLAY",
90
+ "special": "INFO",
91
+ "1h": "START",
92
+ "2h": "START",
93
+ }
@@ -0,0 +1,434 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Callable
6
+
7
+ from .model import EVENT_ICONS, STAT_LABELS, MatchState
8
+
9
+ BG = "#0a0f0d"
10
+ PANEL = "#101814"
11
+ PANEL_2 = "#15211b"
12
+ LINE = "#294036"
13
+ TEXT = "#eef5ef"
14
+ MUTED = "#82958b"
15
+ GREEN = "#54e08b"
16
+ LIME = "#c8f560"
17
+ AMBER = "#f4c95d"
18
+ RED = "#ff6b6b"
19
+ BLUE = "#68a8ff"
20
+
21
+
22
+ def _rgb(value: str) -> tuple[int, int, int]:
23
+ value = value.lstrip("#")
24
+ return tuple(int(value[i:i + 2], 16) for i in (0, 2, 4)) # type: ignore[return-value]
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Style:
29
+ fg: str = TEXT
30
+ bg: str = BG
31
+ bold: bool = False
32
+ dim: bool = False
33
+
34
+
35
+ @dataclass
36
+ class Cell:
37
+ char: str = " "
38
+ style: Style = Style()
39
+
40
+
41
+ class Canvas:
42
+ def __init__(self, width: int, height: int):
43
+ self.width = max(40, width)
44
+ self.height = max(16, height)
45
+ self.rows = [[Cell() for _ in range(self.width)] for _ in range(self.height)]
46
+
47
+ def put(self, x: int, y: int, text: object, style: Style = Style(), max_width: int | None = None) -> None:
48
+ if not 0 <= y < self.height:
49
+ return
50
+ value = str(text).replace("\n", " ")
51
+ room = self.width - x if max_width is None else min(max_width, self.width - x)
52
+ if room <= 0:
53
+ return
54
+ if len(value) > room:
55
+ value = value[:max(0, room - 1)] + ("…" if room else "")
56
+ for offset, char in enumerate(value):
57
+ px = x + offset
58
+ if 0 <= px < self.width:
59
+ self.rows[y][px] = Cell(char, style)
60
+
61
+ def fill(self, x: int, y: int, width: int, height: int, style: Style, char: str = " ") -> None:
62
+ for py in range(max(0, y), min(self.height, y + height)):
63
+ for px in range(max(0, x), min(self.width, x + width)):
64
+ self.rows[py][px] = Cell(char, style)
65
+
66
+ def box(self, x: int, y: int, width: int, height: int, title: str = "") -> None:
67
+ if width < 3 or height < 2:
68
+ return
69
+ border = Style(LINE, PANEL)
70
+ self.fill(x, y, width, height, Style(TEXT, PANEL))
71
+ self.put(x, y, "┌" + "─" * (width - 2) + "┐", border)
72
+ self.put(x, y + height - 1, "└" + "─" * (width - 2) + "┘", border)
73
+ for row in range(y + 1, y + height - 1):
74
+ self.put(x, row, "│", border)
75
+ self.put(x + width - 1, row, "│", border)
76
+ if title:
77
+ label = f" {title.upper()} "
78
+ self.put(x + 2, y, label, Style(LIME, PANEL, bold=True), width - 4)
79
+
80
+ def render(self) -> str:
81
+ output: list[str] = []
82
+ previous: Style | None = None
83
+ for row in self.rows:
84
+ for cell in row:
85
+ if cell.style != previous:
86
+ fg = _rgb(cell.style.fg)
87
+ bg = _rgb(cell.style.bg)
88
+ attrs = [f"38;2;{fg[0]};{fg[1]};{fg[2]}", f"48;2;{bg[0]};{bg[1]};{bg[2]}"]
89
+ if cell.style.bold:
90
+ attrs.append("1")
91
+ if cell.style.dim:
92
+ attrs.append("2")
93
+ output.append("\x1b[0;" + ";".join(attrs) + "m")
94
+ previous = cell.style
95
+ output.append(cell.char)
96
+ output.append("\x1b[0m\n")
97
+ previous = None
98
+ return "".join(output)
99
+
100
+
101
+ TABS = ["Overview", "Live", "Stats", "Lineups", "Formation", "Players", "H2H", "Bracket"]
102
+
103
+
104
+ class Renderer:
105
+ def __init__(self, state: MatchState, tab: int = 0, scroll: int = 0, demo: bool = False):
106
+ self.state = state
107
+ self.tab = tab
108
+ self.scroll = max(0, scroll)
109
+ self.demo = demo
110
+
111
+ def render(self, width: int, height: int) -> str:
112
+ c = Canvas(width, height)
113
+ self._header(c)
114
+ self._tabs(c)
115
+ content_y = 9
116
+ content_h = c.height - content_y - 2
117
+ renderers: list[Callable[[Canvas, int, int], None]] = [
118
+ self._overview, self._live, self._stats, self._lineups,
119
+ self._formation, self._players, self._h2h, self._bracket,
120
+ ]
121
+ renderers[self.tab](c, content_y, content_h)
122
+ self._footer(c)
123
+ return c.render()
124
+
125
+ def _header(self, c: Canvas) -> None:
126
+ c.fill(0, 0, c.width, 7, Style(TEXT, PANEL_2))
127
+ live_color = RED if self.state.is_live else MUTED
128
+ c.put(2, 1, "TOUCHLINE", Style(LIME, PANEL_2, bold=True))
129
+ c.put(13, 1, "●", Style(live_color, PANEL_2, bold=True))
130
+ c.put(15, 1, "LIVE" if self.state.is_live else self.state.status.upper(), Style(live_color, PANEL_2, bold=True))
131
+ descriptor = f"{self.state.competition} / {self.state.round_name}"
132
+ c.put(2, 3, descriptor, Style(MUTED, PANEL_2), max(0, c.width // 3 - 2))
133
+
134
+ score = f"{self.state.home.score} : {self.state.away.score}"
135
+ centre = c.width // 2
136
+ c.put(max(2, centre - len(score) // 2), 2, score, Style(TEXT, PANEL_2, bold=True))
137
+ home = f"{self.state.home.short.upper()} {self.state.home.name}"
138
+ away = f"{self.state.away.name} {self.state.away.short.upper()}"
139
+ c.put(max(2, centre - 8 - len(home)), 2, home, Style(GREEN, PANEL_2, bold=True))
140
+ c.put(centre + 9, 2, away, Style(BLUE, PANEL_2, bold=True), max(0, c.width - centre - 11))
141
+ phase = f"{self.state.minute}' {self.state.period}" if self.state.is_live else self.state.period
142
+ c.put(max(2, centre - len(phase) // 2), 4, phase, Style(AMBER, PANEL_2, bold=True))
143
+ location = " · ".join(part for part in (self.state.venue, self.state.city) if part)
144
+ c.put(max(2, centre - len(location) // 2), 5, location, Style(MUTED, PANEL_2), c.width - 4)
145
+
146
+ updated = self.state.updated_at.strftime("%H:%M:%S") if self.state.updated_at else "waiting"
147
+ status = f"SYNC {updated}"
148
+ if self.demo:
149
+ status = "DEMO DATA"
150
+ c.put(max(2, c.width - len(status) - 2), 1, status, Style(MUTED, PANEL_2))
151
+ c.put(0, 6, "─" * c.width, Style(LINE, PANEL_2))
152
+
153
+ def _tabs(self, c: Canvas) -> None:
154
+ c.fill(0, 7, c.width, 2, Style(MUTED, BG))
155
+ x = 2
156
+ compact = c.width < 100
157
+ for index, name in enumerate(TABS):
158
+ label = f" {index + 1} {name if not compact else name[:4]} "
159
+ if x + len(label) >= c.width - 1:
160
+ break
161
+ selected = index == self.tab
162
+ c.put(x, 7, label, Style(BG if selected else MUTED, LIME if selected else BG, bold=selected))
163
+ x += len(label) + 1
164
+
165
+ def _footer(self, c: Canvas) -> None:
166
+ y = c.height - 1
167
+ c.fill(0, y, c.width, 1, Style(MUTED, PANEL_2))
168
+ left = " ←/→ views j/k scroll r refresh q quit"
169
+ c.put(0, y, left, Style(MUTED, PANEL_2))
170
+ if self.state.error:
171
+ c.put(max(1, c.width - len(self.state.error) - 2), y, self.state.error, Style(RED, PANEL_2), c.width // 2)
172
+
173
+ def _overview(self, c: Canvas, y: int, h: int) -> None:
174
+ if c.width < 88:
175
+ self._event_panel(c, 1, y, c.width - 2, h)
176
+ return
177
+ left = int(c.width * 0.61)
178
+ self._event_panel(c, 1, y, left - 2, h)
179
+ self._snapshot_panel(c, left, y, c.width - left - 1, h)
180
+
181
+ def _event_panel(self, c: Canvas, x: int, y: int, w: int, h: int) -> None:
182
+ c.box(x, y, w, h, "Latest from the pitch")
183
+ events = self.state.events[self.scroll:]
184
+ row = y + 2
185
+ for event in events:
186
+ if row >= y + h - 1:
187
+ break
188
+ icon = EVENT_ICONS.get(event.kind, event.kind[:5].upper())
189
+ accent = RED if event.kind == "goal" else AMBER if "card" in event.kind else GREEN
190
+ c.put(x + 2, row, f"{event.minute:>3}'", Style(MUTED, PANEL))
191
+ c.put(x + 8, row, f"{icon:<6}", Style(accent, PANEL, bold=True))
192
+ c.put(x + 15, row, event.text, Style(TEXT, PANEL), w - 17)
193
+ row += 2 if w < 70 else 1
194
+ if not events:
195
+ self._empty(c, x, y, w, h, "Waiting for the first match event…")
196
+
197
+ def _snapshot_panel(self, c: Canvas, x: int, y: int, w: int, h: int) -> None:
198
+ c.box(x, y, w, h, "Match pulse")
199
+ keys = ["possession", "expected_goal", "goal_attempt", "shoton"]
200
+ row = y + 2
201
+ for key in keys:
202
+ if row >= y + h - 2:
203
+ break
204
+ value = self.state.stats.get(key)
205
+ if not value:
206
+ continue
207
+ self._stat_row(c, x + 2, row, w - 4, STAT_LABELS.get(key, key), value)
208
+ row += 2
209
+ if self.state.momentum and row + 4 < y + h:
210
+ c.put(x + 2, row + 1, "MOMENTUM", Style(MUTED, PANEL, bold=True))
211
+ self._momentum(c, x + 2, row + 3, w - 4)
212
+ elif row == y + 2:
213
+ self._empty(c, x, y, w, h, "Stats will appear as play develops")
214
+
215
+ def _live(self, c: Canvas, y: int, h: int) -> None:
216
+ if c.width < 100:
217
+ self._event_panel(c, 1, y, c.width - 2, h)
218
+ return
219
+ left = int(c.width * 0.68)
220
+ self._event_panel(c, 1, y, left - 2, h)
221
+ self._pitch_map(c, left, y, c.width - left - 1, h)
222
+
223
+ def _momentum(self, c: Canvas, x: int, y: int, w: int, show_labels: bool = True) -> None:
224
+ points = self.state.momentum[-w:]
225
+ if not points:
226
+ return
227
+ maximum = max(max(abs(point) for point in points), 1)
228
+ glyphs = "▁▂▃▄▅▆▇█"
229
+ for index, point in enumerate(points):
230
+ level = min(7, int(abs(point) / maximum * 7))
231
+ color = GREEN if point >= 0 else BLUE
232
+ c.put(x + index, y, glyphs[level], Style(color, PANEL, bold=abs(point) == maximum))
233
+ if show_labels:
234
+ c.put(x, y + 1, self.state.home.short, Style(GREEN, PANEL), min(8, w))
235
+ away = self.state.away.short
236
+ c.put(x + max(0, w - len(away)), y + 1, away, Style(BLUE, PANEL))
237
+
238
+ def _pitch_map(self, c: Canvas, x: int, y: int, w: int, h: int) -> None:
239
+ c.box(x, y, w, h, "Action map")
240
+ px, py = x + 2, y + 2
241
+ pw, ph = w - 4, max(5, h - 8)
242
+ c.fill(px, py, pw, ph, Style(TEXT, "#123323"))
243
+ c.put(px, py, "┌" + "─" * max(0, pw - 2) + "┐", Style("#3b7657", "#123323"))
244
+ c.put(px, py + ph - 1, "└" + "─" * max(0, pw - 2) + "┘", Style("#3b7657", "#123323"))
245
+ for row in range(py + 1, py + ph - 1):
246
+ c.put(px, row, "│", Style("#3b7657", "#123323"))
247
+ c.put(px + pw - 1, row, "│", Style("#3b7657", "#123323"))
248
+ c.put(px + pw // 2, row, "│", Style("#3b7657", "#123323"))
249
+ c.put(px + pw // 2, py + ph // 2, "○", Style("#3b7657", "#123323"))
250
+ actions = self.state.pitch_actions[-40:]
251
+ for index, action in enumerate(actions):
252
+ try:
253
+ ax = float(action.get("coords_x", 0)) / 100
254
+ ay = float(action.get("coords_y", 0)) / 100
255
+ except (TypeError, ValueError):
256
+ continue
257
+ plot_x = px + 1 + int(ax * max(1, pw - 3))
258
+ plot_y = py + 1 + int(ay * max(1, ph - 3))
259
+ home = str(action.get("ti", "")) == self.state.home.id
260
+ color = GREEN if home else BLUE
261
+ marker = "●" if index == len(actions) - 1 else "·"
262
+ c.put(plot_x, plot_y, marker, Style(color, "#123323", bold=marker == "●"))
263
+ legend_y = y + h - 4
264
+ c.put(x + 2, legend_y, f"● {self.state.home.short}", Style(GREEN, PANEL, bold=True))
265
+ away = f"● {self.state.away.short}"
266
+ c.put(x + max(2, w - len(away) - 2), legend_y, away, Style(BLUE, PANEL, bold=True))
267
+ if self.state.momentum:
268
+ c.put(x + 2, legend_y + 1, "MOMENTUM", Style(MUTED, PANEL, bold=True))
269
+ self._momentum(c, x + 2, legend_y + 2, w - 4, show_labels=False)
270
+
271
+ def _stat_row(self, c: Canvas, x: int, y: int, w: int, label: str, values: dict[str, str]) -> None:
272
+ home = values.get("home", "0")
273
+ away = values.get("away", "0")
274
+ c.put(x, y, f"{home:>5}", Style(GREEN, PANEL, bold=True))
275
+ c.put(x + max(7, (w - len(label)) // 2), y, label, Style(MUTED, PANEL), w - 14)
276
+ c.put(x + w - 5, y, f"{away:<5}", Style(BLUE, PANEL, bold=True))
277
+ try:
278
+ hv, av = float(home), float(away)
279
+ except ValueError:
280
+ hv = av = 0
281
+ total = max(hv + av, 1)
282
+ bar_w = max(4, w - 14)
283
+ home_w = int(bar_w * hv / total)
284
+ c.put(x + 7, y + 1, "━" * home_w, Style(GREEN, PANEL))
285
+ c.put(x + 7 + home_w, y + 1, "━" * (bar_w - home_w), Style(BLUE, PANEL))
286
+
287
+ def _stats(self, c: Canvas, y: int, h: int) -> None:
288
+ c.box(1, y, c.width - 2, h, "Team statistics")
289
+ c.put(4, y + 1, self.state.home.name, Style(GREEN, PANEL, bold=True), c.width // 3)
290
+ away = self.state.away.name
291
+ c.put(max(4, c.width - len(away) - 4), y + 1, away, Style(BLUE, PANEL, bold=True))
292
+ preferred = list(STAT_LABELS)
293
+ keys = [key for key in preferred if key in self.state.stats]
294
+ keys += [key for key in self.state.stats if key not in keys]
295
+ row = y + 3
296
+ for key in keys[self.scroll:]:
297
+ if row >= y + h - 1:
298
+ break
299
+ self._stat_row(c, 4, row, c.width - 8, STAT_LABELS.get(key, key.replace("_", " ").title()), self.state.stats[key])
300
+ row += 2
301
+ if not keys:
302
+ self._empty(c, 1, y, c.width - 2, h, "Stats will appear as play develops")
303
+
304
+ def _lineups(self, c: Canvas, y: int, h: int) -> None:
305
+ gap = 1
306
+ width = (c.width - 3 - gap) // 2
307
+ self._squad(c, 1, y, width, h, "home")
308
+ self._squad(c, 2 + width, y, c.width - width - 3, h, "away")
309
+
310
+ def _squad(self, c: Canvas, x: int, y: int, w: int, h: int, side: str) -> None:
311
+ team = self.state.home if side == "home" else self.state.away
312
+ accent = GREEN if side == "home" else BLUE
313
+ c.box(x, y, w, h, f"{team.name} · {self.state.formations.get(side, '')}")
314
+ starters = self.state.lineups.get("lineups", {}).get(side, [])
315
+ bench = self.state.lineups.get("bench", {}).get(side, [])
316
+ rows = [("STARTING XI", None)] + [("", p) for p in starters] + [("BENCH", None)] + [("", p) for p in bench]
317
+ row = y + 2
318
+ for label, player in rows[self.scroll:]:
319
+ if row >= y + h - 1:
320
+ break
321
+ if player is None:
322
+ c.put(x + 2, row, label, Style(MUTED, PANEL, bold=True), w - 4)
323
+ else:
324
+ number = str(player.get("sn", ""))
325
+ name = str(player.get("pn") or player.get("pns") or "—")
326
+ c.put(x + 2, row, f"{number:>2}", Style(accent, PANEL, bold=True))
327
+ c.put(x + 6, row, name, Style(TEXT, PANEL), w - 8)
328
+ row += 1
329
+ if not starters:
330
+ self._empty(c, x, y, w, h, "Lineups not announced")
331
+
332
+ def _formation(self, c: Canvas, y: int, h: int) -> None:
333
+ gap = 1
334
+ width = (c.width - 3 - gap) // 2
335
+ self._pitch(c, 1, y, width, h, "home")
336
+ self._pitch(c, 2 + width, y, c.width - width - 3, h, "away")
337
+
338
+ def _pitch(self, c: Canvas, x: int, y: int, w: int, h: int, side: str) -> None:
339
+ team = self.state.home if side == "home" else self.state.away
340
+ accent = GREEN if side == "home" else BLUE
341
+ formation = self.state.formations.get(side, "")
342
+ c.box(x, y, w, h, f"{team.name} · {formation or 'TBC'}")
343
+ players = self.state.lineups.get("lineups", {}).get(side, [])
344
+ if not players:
345
+ self._empty(c, x, y, w, h, "Formation not available")
346
+ return
347
+ inner_h = h - 3
348
+ c.fill(x + 2, y + 2, w - 4, inner_h, Style(TEXT, "#123323"))
349
+ c.put(x + 2, y + 2 + inner_h // 2, "─" * (w - 4), Style("#2d664a", "#123323"))
350
+ groups: list[list[dict]] = []
351
+ for player in players:
352
+ pos = int(player.get("so", 0) or 0)
353
+ while len(groups) <= pos:
354
+ groups.append([])
355
+ groups[pos].append(player)
356
+ groups = [group for group in groups if group]
357
+ for group_index, group in enumerate(groups):
358
+ py = y + 3 + int(group_index * max(1, inner_h - 2) / max(1, len(groups) - 1))
359
+ for index, player in enumerate(group):
360
+ name = str(player.get("pns") or player.get("pn") or "—")
361
+ if w < 48:
362
+ name = name.split()[-1][:8]
363
+ slot = (w - 6) / len(group)
364
+ px = x + 3 + int((index + 0.5) * slot - len(name) / 2)
365
+ c.put(px, py, name, Style(TEXT, "#123323", bold=True), int(slot))
366
+ c.put(max(x + 2, px - 2), py, "●", Style(accent, "#123323", bold=True))
367
+
368
+ def _players(self, c: Canvas, y: int, h: int) -> None:
369
+ c.box(1, y, c.width - 2, h, "Player statistics")
370
+ headers = "PLAYER TEAM MIN RATING xG G A"
371
+ c.put(3, y + 2, headers, Style(MUTED, PANEL, bold=True), c.width - 6)
372
+ row = y + 4
373
+ for player in self.state.player_stats[self.scroll:]:
374
+ if row >= y + h - 1:
375
+ break
376
+ name = str(player.get("pn") or player.get("participant_name") or "—")
377
+ team = str(player.get("tn") or player.get("team_name") or "—")
378
+ values = [player.get(key, player.get(f"sd_{key}", "0")) for key in ("min", "rating", "expected_goal", "goals", "assists")]
379
+ line = f"{name:<30} {team:<9} {str(values[0]):>4} {str(values[1]):>9} {str(values[2]):>6} {str(values[3]):>4} {str(values[4]):>4}"
380
+ c.put(3, row, line, Style(TEXT, PANEL), c.width - 6)
381
+ row += 1
382
+ if not self.state.player_stats:
383
+ self._empty(c, 1, y, c.width - 2, h, "Player ratings appear once enough match data is collected")
384
+
385
+ def _h2h(self, c: Canvas, y: int, h: int) -> None:
386
+ c.box(1, y, c.width - 2, h, "Head to head · last meetings")
387
+ games = self.state.h2h.get("h2h", [])
388
+ row = y + 2
389
+ for game in games[self.scroll:]:
390
+ if row >= y + h - 1:
391
+ break
392
+ date = game.get("date", "")
393
+ home, away = game.get("home", "—"), game.get("away", "—")
394
+ score = f"{game.get('home_score', '–')} : {game.get('away_score', '–')}"
395
+ c.put(3, row, date, Style(MUTED, PANEL))
396
+ centre = c.width // 2
397
+ c.put(max(15, centre - len(str(home)) - 6), row, home, Style(TEXT, PANEL, bold=True))
398
+ c.put(centre - 3, row, score, Style(LIME, PANEL, bold=True))
399
+ c.put(centre + 5, row, away, Style(TEXT, PANEL, bold=True), c.width - centre - 8)
400
+ row += 2
401
+ if not games:
402
+ self._empty(c, 1, y, c.width - 2, h, "No previous meetings found")
403
+
404
+ def _bracket(self, c: Canvas, y: int, h: int) -> None:
405
+ c.box(1, y, c.width - 2, h, "Road to the final")
406
+ rounds = sorted(self.state.bracket.items(), key=lambda item: -len(item[1]))
407
+ if not rounds:
408
+ self._empty(c, 1, y, c.width - 2, h, "Tournament bracket not available")
409
+ return
410
+ columns = min(len(rounds), 3 if c.width >= 100 else 2)
411
+ col_w = (c.width - 6) // columns
412
+ for col, (round_name, games) in enumerate(rounds[-columns:]):
413
+ x = 3 + col * col_w
414
+ c.put(x, y + 2, round_name.upper(), Style(MUTED, PANEL, bold=True), col_w - 2)
415
+ row = y + 4
416
+ for game in games:
417
+ if row + 2 >= y + h:
418
+ break
419
+ home = str(game.get("home", "—"))
420
+ away = str(game.get("away", "—"))
421
+ c.put(x, row, home, Style(TEXT, PANEL), col_w - 7)
422
+ c.put(x + col_w - 5, row, str(game.get("home_score", "–")), Style(LIME, PANEL, bold=True))
423
+ c.put(x, row + 1, away, Style(TEXT, PANEL), col_w - 7)
424
+ c.put(x + col_w - 5, row + 1, str(game.get("away_score", "–")), Style(LIME, PANEL, bold=True))
425
+ c.put(x, row + 2, "─" * max(2, col_w - 3), Style(LINE, PANEL))
426
+ row += 4
427
+
428
+ def _empty(self, c: Canvas, x: int, y: int, w: int, h: int, message: str) -> None:
429
+ px = max(x + 2, x + (w - len(message)) // 2)
430
+ c.put(px, y + h // 2, message, Style(MUTED, PANEL), w - 4)
431
+
432
+
433
+ def strip_ansi(value: str) -> str:
434
+ return re.sub(r"\x1b\[[0-9;]*m", "", value)