aboutsummaryrefslogtreecommitdiff
path: root/teletext-tui.py
diff options
context:
space:
mode:
Diffstat (limited to 'teletext-tui.py')
-rw-r--r--teletext-tui.py398
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()