diff options
Diffstat (limited to 'teletext-tui.py')
-rw-r--r-- | teletext-tui.py | 398 |
1 files changed, 0 insertions, 398 deletions
diff --git a/teletext-tui.py b/teletext-tui.py deleted file mode 100644 index abe668d..0000000 --- a/teletext-tui.py +++ /dev/null @@ -1,398 +0,0 @@ -#!/usr/bin/env python3 - -""" -Teletext TUI for api.teletext.ch -""" - -import argparse -import base64 -import curses -import textwrap -import time -from dataclasses import dataclass -from typing import List, Optional, Tuple - -import requests - -API_BASE = "https://api.teletext.ch" -CHANNELS_DEFAULT = ["srf1", "srfzwei", "srfinfo"] - -# ───────── Models ───────── - - -@dataclass -class Subpage: - subpageNumber: int - contentText: str - ep1Info: Optional[dict] = None - - -@dataclass -class PageData: - channel: str - pageNumber: int - nextPage: Optional[int] - subpages: List[Subpage] - previousPage: Optional[int] = None - - -# ───────── Fetch ───────── - - -def fetch_page(channel: str, page: int, timeout: float = 6.0) -> PageData: - """Fetch a Teletext page JSON from the API.""" - url = f"{API_BASE}/channels/{channel}/pages/{page}" - r = requests.get(url, timeout=timeout, headers={"Accept": "application/json"}) - r.raise_for_status() - - j = r.json() - subs: List[Subpage] = [ - Subpage( - subpageNumber=sp.get("subpageNumber", 0), - contentText=sp.get("contentText") or "", - ep1Info=sp.get("ep1Info"), - ) - for sp in j.get("subpages", []) or [] - ] - - if not subs: - subs = [Subpage(0, "", None)] - - return PageData( - channel=j.get("channel", channel), - pageNumber=j.get("pageNumber", page), - nextPage=j.get("nextPage"), - subpages=subs, - previousPage=j.get("previousPage"), - ) - - -# ───────── Teletext decode ───────── - -GERMAN_NATIONAL_MAP = { - 0x5B: "Ä", - 0x5C: "Ö", - 0x5D: "Ü", - 0x7B: "ä", - 0x7C: "ö", - 0x7D: "ü", - 0x7E: "ß", -} - - -def alpha_char(b: int) -> str: - """Map teletext character codes to Unicode.""" - return GERMAN_NATIONAL_MAP.get(b, chr(b)) - - -def ep1_rows_text_only(ep1_info: dict) -> List[List[Tuple[str, int, int, bool]]]: - """Decode EP1-format data into rows of (text, fg, bg, bold) segments.""" - ep1 = ep1_info.get("data", {}).get("ep1Format", {}) - header_b = base64.b64decode(ep1.get("header", "") or b"") - content_b = base64.b64decode(ep1.get("content", "") or b"") - - def render_row(bs: bytes) -> List[Tuple[str, int, int, bool]]: - alpha = True - fg, bg = 7, -1 - double = False - segs: List[Tuple[str, int, int, bool]] = [] - buf: List[str] = [] - - def flush(): - nonlocal buf - if buf: - segs.append(("".join(buf), fg, bg, double)) - buf = [] - - i = 0 - while i < len(bs): - b = bs[i] - if b < 0x20: - if 0x00 <= b <= 0x07: - flush() - fg = b - alpha = True - elif b == 0x0C: - flush() - double = False - elif b == 0x0D: - flush() - double = True - elif b == 0x0F: - flush() - alpha = False - elif 0x10 <= b <= 0x17: - flush() - fg = b - 0x10 - alpha = False - elif b == 0x1C: - flush() - bg = -1 - elif b == 0x1D: - flush() - bg = fg - elif b == 0x1F: - alpha = True - i += 1 - continue - - buf.append(alpha_char(b) if alpha else " ") - i += 1 - - flush() - total = sum(len(s) for s, _, _, _ in segs) - if total < 40: - segs.append((" " * (40 - total), fg, bg, double)) - elif total > 40: - over = total - 40 - s, cf, cb, db = segs[-1] - segs[-1] = (s[:-over], cf, cb, db) - return segs - - rows = [] - if len(header_b) >= 40: - rows.append(render_row(header_b[:40])) - for i in range(0, len(content_b), 40): - chunk = content_b[i : i + 40] - if not chunk: - break - rows.append(render_row(chunk)) - - def is_blank(row): - return "".join(s for s, _, _, _ in row).strip() == "" - - while rows and is_blank(rows[-1]): - rows.pop() - return rows - - -# ───────── UI helpers ───────── - - -def wrap_plain(text: str, width: int) -> List[str]: - """Wrap plain text into lines without breaking whitespace.""" - out = [] - for line in text.splitlines(): - out.extend( - textwrap.wrap( - line, width=width, replace_whitespace=False, drop_whitespace=False - ) - or [""] - ) - return out - - -def draw_bar(stdscr, msg, row, width, pair=0): - """Draw a single-line colored bar.""" - msg = (msg[: width - 1]).ljust(width - 1) - if pair: - stdscr.attron(curses.color_pair(pair)) - stdscr.addstr(row, 0, msg) - if pair: - stdscr.attroff(curses.color_pair(pair)) - - -# ───────── TUI ───────── - - -def run_tui(initial_channel: str, initial_page: int, channels: List[str]): - def main(stdscr): - curses.curs_set(0) - stdscr.nodelay(True) - stdscr.timeout(100) - curses.start_color() - curses.use_default_colors() - - curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_CYAN) # header - curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_WHITE) # footer - curses.init_pair(3, curses.COLOR_RED, -1) # error - - pair_base = 20 - pair_idx = {} - palette = [ - curses.COLOR_BLACK, - curses.COLOR_RED, - curses.COLOR_GREEN, - curses.COLOR_YELLOW, - curses.COLOR_BLUE, - curses.COLOR_MAGENTA, - curses.COLOR_CYAN, - curses.COLOR_WHITE, - ] - - def pair_for(fg: int, bg: int) -> int: - key = (fg, bg) - if key in pair_idx: - return pair_idx[key] - pid = pair_base + len(pair_idx) - curses.init_pair( - pid, - palette[max(0, min(7, fg))], - -1 if bg == -1 else palette[max(0, min(7, bg))], - ) - pair_idx[key] = pid - return pid - - channel_idx = next( - (i for i, c in enumerate(channels) if c == initial_channel), 0 - ) - channel = channels[channel_idx] - page = initial_page - sub_idx = 0 - input_buf = "" - status = "Ready." - last_status_at = time.time() - - try: - pd = fetch_page(channel, page) - status = f"Loaded {channel}:{page}" - except Exception as e: - pd = PageData(channel, page, None, [Subpage(0, f"Error: {e}", None)]) - status = f"Error loading {channel}:{page} – {e}" - - while True: - stdscr.erase() - h, w = stdscr.getmaxyx() - if h < 8 or w < 42: - stdscr.addstr(0, 0, "Please resize terminal (>= 42x8).") - stdscr.refresh() - time.sleep(0.1) - continue - - header = ( - f" Teletext {pd.channel} Page {pd.pageNumber} " - f"Sub {min(sub_idx + 1, len(pd.subpages))}/{max(1, len(pd.subpages))} " - "[q] quit [0..9 Enter] goto [h/l] prev/next [c] channel [r] reload" - ) - draw_bar(stdscr, header, 0, w, 1) - - body_h, body_w = h - 3, w - sp = pd.subpages[min(sub_idx, len(pd.subpages) - 1)] - - if sp.contentText: - for i, line in enumerate(wrap_plain(sp.contentText, body_w)[:body_h]): - stdscr.addnstr(1 + i, 0, line, body_w - 1) - elif sp.ep1Info: - rows = ep1_rows_text_only(sp.ep1Info) - r_i = 0 - for row in rows: - if r_i >= body_h: - break - col = 0 - is_double = any(b for _, _, _, b in row) - for txt, fg, bg, bold in row: - if col >= body_w - 1: - break - chunk = txt[: max(0, body_w - 1 - col)] - attrs = curses.color_pair(pair_for(fg, bg)) | ( - curses.A_BOLD if bold else 0 - ) - stdscr.addstr(1 + r_i, col, chunk, attrs) - col += len(chunk) - r_i += 1 - if is_double and r_i < body_h: - stdscr.addstr(1 + r_i, 0, " " * min(40, body_w - 1)) - r_i += 1 - else: - stdscr.addstr(1, 0, "(empty)") - - draw_bar(stdscr, f"Go to page: {input_buf}", h - 2, w, 2) - if time.time() - last_status_at > 3: - status = "" - draw_bar( - stdscr, status, h - 1, w, 0 if not status.startswith("Error") else 3 - ) - - stdscr.refresh() - ch = stdscr.getch() - if ch == -1: - continue - - if ch in (ord("q"), ord("Q")): - break - if ch in (ord("h"), curses.KEY_LEFT): - if sub_idx > 0: - sub_idx -= 1 - else: - if isinstance(pd.previousPage, int): - page = pd.previousPage - sub_idx = 0 - try: - pd = fetch_page(channel, page) - status = f"Loaded {channel}:{page}" - except Exception as e: - status = f"Error loading {channel}:{page} – {e}" - last_status_at = time.time() - continue - if ch in (ord("l"), curses.KEY_RIGHT): - if sub_idx + 1 < len(pd.subpages): - sub_idx += 1 - else: - if isinstance(pd.nextPage, int): - page = pd.nextPage - sub_idx = 0 - try: - pd = fetch_page(channel, page) - status = f"Loaded {channel}:{page}" - except Exception as e: - status = f"Error loading {channel}:{page} – {e}" - last_status_at = time.time() - continue - if ch in (ord("c"), ord("C")): - channel_idx = (channel_idx + 1) % len(channels) - channel = channels[channel_idx] - sub_idx = 0 - try: - pd = fetch_page(channel, page) - status = f"Channel {channel}" - except Exception as e: - status = f"Error loading {channel}:{page} – {e}" - last_status_at = time.time() - continue - if ch in (ord("r"), ord("R")): - try: - pd = fetch_page(channel, page) - sub_idx = min(sub_idx, len(pd.subpages) - 1) - status = f"Reloaded {channel}:{page}" - except Exception as e: - status = f"Error reloading {channel}:{page} – {e}" - last_status_at = time.time() - continue - if ch in (curses.KEY_BACKSPACE, 127, 8): - input_buf = input_buf[:-1] - continue - if ch in (curses.KEY_ENTER, 10, 13): - if input_buf.strip().isdigit(): - page = int(input_buf) - sub_idx = 0 - try: - pd = fetch_page(channel, page) - status = f"Loaded {channel}:{page}" - except Exception as e: - status = f"Error loading {channel}:{page} – {e}" - last_status_at = time.time() - input_buf = "" - continue - if 48 <= ch <= 57 and len(input_buf) < 4: - input_buf += chr(ch) - - curses.wrapper(main) - - -# ───────── CLI ───────── - - -def parse_args(): - ap = argparse.ArgumentParser(description="Teletext TUI for api.teletext.ch") - ap.add_argument("--channel", default="srf1") - ap.add_argument("--page", type=int, default=100) - return ap.parse_args() - - -def main(): - args = parse_args() - run_tui(args.channel, args.page, CHANNELS_DEFAULT) - - -if __name__ == "__main__": - main() |