diff options
author | Flavian Kaufmann <flavian@flaviankaufmann.ch> | 2025-08-12 16:48:03 +0200 |
---|---|---|
committer | Flavian Kaufmann <flavian@flaviankaufmann.ch> | 2025-08-12 16:48:03 +0200 |
commit | d5c89e65e7e96ec2f79c2faa32869744d4e429b5 (patch) | |
tree | d819879b898bb7cca046c34e1550bf3f8a47df31 | |
download | tltxch-d5c89e65e7e96ec2f79c2faa32869744d4e429b5.tar.gz tltxch-d5c89e65e7e96ec2f79c2faa32869744d4e429b5.zip |
initial
-rw-r--r-- | README.md | 6 | ||||
-rw-r--r-- | teletext-tui.py | 398 |
2 files changed, 404 insertions, 0 deletions
diff --git a/README.md b/README.md new file mode 100644 index 0000000..e3e98cb --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# Teletext TUI (Swiss Teletext Terminal User Interface) + +Python program that fetches (Swiss) teletext from `api.teletext.ch` and displays +it in the terminal using curses. + + diff --git a/teletext-tui.py b/teletext-tui.py new file mode 100644 index 0000000..abe668d --- /dev/null +++ b/teletext-tui.py @@ -0,0 +1,398 @@ +#!/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() |