diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..b3f8c89 Binary files /dev/null and b/.DS_Store differ diff --git a/README.md b/README.md index b5076a5..19de862 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ All constants live in `engine/config.py`: | `HEADLINE_LIMIT` | `1000` | Total headlines per session | | `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) | | `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike | -| `FONT_PATH` | hardcoded path | Path to your OTF/TTF display font | +| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files used by the font picker | +| `FONT_PATH` | first supported font in `fonts/` | Active display font file selected at startup | | `FONT_SZ` | `60` | Font render size (affects block density) | | `RENDER_H` | `8` | Terminal rows per headline line | | `SSAA` | `4` | Super-sampling factor (render at 4× then downsample) | @@ -41,7 +42,7 @@ All constants live in `engine/config.py`: | `NTFY_POLL_INTERVAL` | `15` | Seconds between ntfy polls | | `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen | -**Font:** `FONT_PATH` is hardcoded to a local path. Update it to point to whatever display font you want — anything with strong contrast and wide letterforms works well. +**Font:** Put your `.otf`, `.ttf`, or `.ttc` files in `fonts/`. Startup opens the font picker from that folder and applies your selected font before streaming. --- diff --git a/engine/app.py b/engine/app.py index 4a89098..98cddd1 100644 --- a/engine/app.py +++ b/engine/app.py @@ -3,11 +3,14 @@ Application orchestrator — boot sequence, signal handling, main loop wiring. """ import sys +import os import time import signal import atexit +import termios +import tty -from engine import config +from engine import config, render from engine.terminal import ( RST, G_HI, G_MID, G_DIM, W_DIM, W_GHOST, CLR, CURSOR_OFF, CURSOR_ON, tw, slow_print, boot_ln, @@ -26,6 +29,208 @@ TITLE = [ " ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝", ] +def _read_picker_key(): + ch = sys.stdin.read(1) + if ch == "\x03": + return "interrupt" + if ch in ("\r", "\n"): + return "enter" + if ch == "\x1b": + c1 = sys.stdin.read(1) + if c1 != "[": + return None + c2 = sys.stdin.read(1) + if c2 == "A": + return "up" + if c2 == "B": + return "down" + return None + if ch in ("k", "K"): + return "up" + if ch in ("j", "J"): + return "down" + if ch in ("q", "Q"): + return "enter" + return None + +def _normalize_preview_rows(rows): + """Trim shared left padding and trailing spaces for stable on-screen previews.""" + non_empty = [r for r in rows if r.strip()] + if not non_empty: + return [""] + left_pad = min(len(r) - len(r.lstrip(" ")) for r in non_empty) + out = [] + for row in rows: + if left_pad < len(row): + out.append(row[left_pad:].rstrip()) + else: + out.append(row.rstrip()) + return out + + +def _draw_font_picker(faces, selected): + w = tw() + h = 24 + try: + h = os.get_terminal_size().lines + except Exception: + pass + + max_preview_w = max(24, w - 8) + header_h = 6 + footer_h = 3 + preview_h = max(4, min(config.RENDER_H + 2, max(4, h // 2))) + visible = max(1, h - header_h - preview_h - footer_h) + top = max(0, selected - (visible // 2)) + bottom = min(len(faces), top + visible) + top = max(0, bottom - visible) + + print(CLR, end="") + print(CURSOR_OFF, end="") + print() + print(f" {G_HI}FONT PICKER{RST}") + print(f" {W_GHOST}{'─' * (w - 4)}{RST}") + print(f" {W_DIM}{config.FONT_DIR[:max_preview_w]}{RST}") + print(f" {W_GHOST}↑/↓ move · Enter select · q accept current{RST}") + print() + + for pos in range(top, bottom): + face = faces[pos] + active = pos == selected + pointer = "▶" if active else " " + color = G_HI if active else W_DIM + print(f" {color}{pointer} {face['name']}{RST}{W_GHOST} · {face['file_name']}{RST}") + + if top > 0: + print(f" {W_GHOST}… {top} above{RST}") + if bottom < len(faces): + print(f" {W_GHOST}… {len(faces) - bottom} below{RST}") + + print() + print(f" {W_GHOST}{'─' * (w - 4)}{RST}") + print( + f" {W_DIM}Preview: {faces[selected]['name']} · {faces[selected]['file_name']}{RST}" + ) + preview_rows = faces[selected]["preview_rows"][:preview_h] + for row in preview_rows: + shown = row[:max_preview_w] + print(f" {shown}") + +def pick_font_face(): + """Interactive startup picker for selecting a face from repo OTF files.""" + if not config.FONT_PICKER: + return + + font_files = config.list_repo_font_files() + if not font_files: + print(CLR, end="") + print(CURSOR_OFF, end="") + print() + print(f" {G_HI}FONT PICKER{RST}") + print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}") + print(f" {G_DIM}> no .otf/.ttf/.ttc files found in: {config.FONT_DIR}{RST}") + print(f" {W_GHOST}> add font files to the fonts folder, then rerun{RST}") + time.sleep(1.8) + sys.exit(1) + + prepared = [] + for font_path in font_files: + try: + faces = render.list_font_faces(font_path, max_faces=64) + except Exception: + fallback = os.path.splitext(os.path.basename(font_path))[0] + faces = [{"index": 0, "name": fallback}] + for face in faces: + idx = face["index"] + name = face["name"] + file_name = os.path.basename(font_path) + try: + fnt = render.load_font_face(font_path, idx) + rows = _normalize_preview_rows(render.render_line(name, fnt)) + except Exception: + rows = ["(preview unavailable)"] + prepared.append( + { + "font_path": font_path, + "font_index": idx, + "name": name, + "file_name": file_name, + "preview_rows": rows, + } + ) + + if not prepared: + print(CLR, end="") + print(CURSOR_OFF, end="") + print() + print(f" {G_HI}FONT PICKER{RST}") + print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}") + print(f" {G_DIM}> no readable font faces found in: {config.FONT_DIR}{RST}") + time.sleep(1.8) + sys.exit(1) + + def _same_path(a, b): + try: + return os.path.samefile(a, b) + except Exception: + return os.path.abspath(a) == os.path.abspath(b) + + selected = next( + ( + i + for i, f in enumerate(prepared) + if _same_path(f["font_path"], config.FONT_PATH) + and f["font_index"] == config.FONT_INDEX + ), + 0, + ) + + if not sys.stdin.isatty(): + selected_font = prepared[selected] + config.set_font_selection( + font_path=selected_font["font_path"], + font_index=selected_font["font_index"], + ) + render.clear_font_cache() + print( + f" {G_DIM}> using {selected_font['name']} ({selected_font['file_name']}){RST}" + ) + time.sleep(0.8) + print(CLR, end="") + print(CURSOR_OFF, end="") + print() + return + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setcbreak(fd) + while True: + _draw_font_picker(prepared, selected) + key = _read_picker_key() + if key == "up": + selected = max(0, selected - 1) + elif key == "down": + selected = min(len(prepared) - 1, selected + 1) + elif key == "enter": + break + elif key == "interrupt": + raise KeyboardInterrupt + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + + selected_font = prepared[selected] + config.set_font_selection( + font_path=selected_font["font_path"], + font_index=selected_font["font_index"], + ) + render.clear_font_cache() + print(f" {G_DIM}> using {selected_font['name']} ({selected_font['file_name']}){RST}") + time.sleep(0.8) + print(CLR, end="") + print(CURSOR_OFF, end="") + print() + def main(): atexit.register(lambda: print(CURSOR_ON, end="", flush=True)) @@ -40,6 +245,8 @@ def main(): w = tw() print(CLR, end="") print(CURSOR_OFF, end="") + pick_font_face() + w = tw() print() time.sleep(0.4) diff --git a/engine/config.py b/engine/config.py index 77d3c1f..f509b23 100644 --- a/engine/config.py +++ b/engine/config.py @@ -4,6 +4,53 @@ Configuration constants, CLI flags, and glyph tables. import sys +from pathlib import Path +_REPO_ROOT = Path(__file__).resolve().parent.parent +_FONT_EXTENSIONS = {".otf", ".ttf", ".ttc"} + + +def _arg_value(flag): + """Get value following a CLI flag, if present.""" + if flag not in sys.argv: + return None + i = sys.argv.index(flag) + return sys.argv[i + 1] if i + 1 < len(sys.argv) else None + + +def _arg_int(flag, default): + """Get int CLI argument with safe fallback.""" + raw = _arg_value(flag) + if raw is None: + return default + try: + return int(raw) + except ValueError: + return default + + +def _resolve_font_path(raw_path): + """Resolve font path; relative paths are anchored to repo root.""" + p = Path(raw_path).expanduser() + if p.is_absolute(): + return str(p) + return str((_REPO_ROOT / p).resolve()) + + +def _list_font_files(font_dir): + """List supported font files within a font directory.""" + font_root = Path(font_dir) + if not font_root.exists() or not font_root.is_dir(): + return [] + return [ + str(path.resolve()) + for path in sorted(font_root.iterdir()) + if path.is_file() and path.suffix.lower() in _FONT_EXTENSIONS + ] + + +def list_repo_font_files(): + """Public helper for discovering repository font files.""" + return _list_font_files(FONT_DIR) # ─── RUNTIME ────────────────────────────────────────────── HEADLINE_LIMIT = 1000 FEED_TIMEOUT = 10 @@ -17,7 +64,16 @@ NTFY_POLL_INTERVAL = 15 # seconds between polls MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen # ─── FONT RENDERING ────────────────────────────────────── -FONT_PATH = "/Users/genejohnson/Documents/CS Bishop Drawn/CSBishopDrawn-Italic.otf" +FONT_DIR = _resolve_font_path(_arg_value('--font-dir') or "fonts") +_FONT_FILE_ARG = _arg_value('--font-file') +_FONT_FILES = _list_font_files(FONT_DIR) +FONT_PATH = ( + _resolve_font_path(_FONT_FILE_ARG) + if _FONT_FILE_ARG + else (_FONT_FILES[0] if _FONT_FILES else "") +) +FONT_INDEX = max(0, _arg_int('--font-index', 0)) +FONT_PICKER = '--no-font-picker' not in sys.argv FONT_SZ = 60 RENDER_H = 8 # terminal rows per rendered text line @@ -33,3 +89,12 @@ GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full swee # ─── GLYPHS ─────────────────────────────────────────────── GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋" KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ" + + +def set_font_selection(font_path=None, font_index=None): + """Set runtime primary font selection.""" + global FONT_PATH, FONT_INDEX + if font_path is not None: + FONT_PATH = _resolve_font_path(font_path) + if font_index is not None: + FONT_INDEX = max(0, int(font_index)) diff --git a/engine/render.py b/engine/render.py index fb7bc5c..1ef5adf 100644 --- a/engine/render.py +++ b/engine/render.py @@ -6,6 +6,7 @@ Depends on: config, terminal, sources, translate. import re import random +from pathlib import Path from PIL import Image, ImageDraw, ImageFont @@ -49,16 +50,55 @@ MSG_GRAD_COLS = [ # ─── FONT LOADING ───────────────────────────────────────── _FONT_OBJ = None +_FONT_OBJ_KEY = None _FONT_CACHE = {} def font(): - """Lazy-load the primary OTF font.""" - global _FONT_OBJ - if _FONT_OBJ is None: - _FONT_OBJ = ImageFont.truetype(config.FONT_PATH, config.FONT_SZ) + """Lazy-load the primary OTF font (path + face index aware).""" + global _FONT_OBJ, _FONT_OBJ_KEY + if not config.FONT_PATH: + raise FileNotFoundError( + f"No primary font selected. Add .otf/.ttf/.ttc files to {config.FONT_DIR}." + ) + key = (config.FONT_PATH, config.FONT_INDEX, config.FONT_SZ) + if _FONT_OBJ is None or _FONT_OBJ_KEY != key: + _FONT_OBJ = ImageFont.truetype( + config.FONT_PATH, config.FONT_SZ, index=config.FONT_INDEX + ) + _FONT_OBJ_KEY = key return _FONT_OBJ +def clear_font_cache(): + """Reset cached font objects after changing primary font selection.""" + global _FONT_OBJ, _FONT_OBJ_KEY + _FONT_OBJ = None + _FONT_OBJ_KEY = None + + +def load_font_face(font_path, font_index=0, size=None): + """Load a specific face from a font file or collection.""" + font_size = size or config.FONT_SZ + return ImageFont.truetype(font_path, font_size, index=font_index) + + +def list_font_faces(font_path, max_faces=64): + """Return discoverable face indexes + display names from a font file.""" + faces = [] + for idx in range(max_faces): + try: + fnt = load_font_face(font_path, idx) + except Exception: + if idx == 0: + raise + break + family, style = fnt.getname() + display = f"{family} {style}".strip() + if not display: + display = f"{Path(font_path).stem} [{idx}]" + faces.append({"index": idx, "name": display}) + return faces + def font_for_lang(lang=None): """Get appropriate font for a language.""" diff --git a/fonts/Eyekons.otf b/fonts/Eyekons.otf new file mode 100644 index 0000000..8e9d2db Binary files /dev/null and b/fonts/Eyekons.otf differ diff --git a/fonts/Pixel Sparta.otf b/fonts/Pixel Sparta.otf new file mode 100644 index 0000000..c8c160a Binary files /dev/null and b/fonts/Pixel Sparta.otf differ diff --git a/fonts/Xeonic.ttf b/fonts/Xeonic.ttf new file mode 100644 index 0000000..f2e29e0 Binary files /dev/null and b/fonts/Xeonic.ttf differ