aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlavian Kaufmann <flavian@flaviankaufmann.ch>2025-08-12 16:48:03 +0200
committerFlavian Kaufmann <flavian@flaviankaufmann.ch>2025-08-12 16:48:03 +0200
commitd5c89e65e7e96ec2f79c2faa32869744d4e429b5 (patch)
treed819879b898bb7cca046c34e1550bf3f8a47df31
downloadtltxch-d5c89e65e7e96ec2f79c2faa32869744d4e429b5.tar.gz
tltxch-d5c89e65e7e96ec2f79c2faa32869744d4e429b5.zip
initial
-rw-r--r--README.md6
-rw-r--r--teletext-tui.py398
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()