diff --git a/engine/app.py b/engine/app.py index 4a89098..68b1845 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,166 @@ 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_PATH[: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['index']}] {face['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']}{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 a font file.""" + if not config.FONT_PICKER: + return + + print(CLR, end="") + print(CURSOR_OFF, end="") + print() + print(f" {G_HI}FONT PICKER{RST}") + print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}") + print(f" {W_DIM}{config.FONT_PATH}{RST}") + print() + + try: + faces = render.list_font_faces(config.FONT_PATH, max_faces=64) + except Exception as exc: + print(f" {G_DIM}> unable to load font file: {exc}{RST}") + print(f" {W_GHOST}> startup aborted (font selection is required){RST}") + time.sleep(1.4) + sys.exit(1) + + face_ids = [face["index"] for face in faces] + default = config.FONT_INDEX if config.FONT_INDEX in face_ids else faces[0]["index"] + + prepared = [] + for face in faces: + idx = face["index"] + name = face["name"] + try: + fnt = render.load_font_face(config.FONT_PATH, idx) + rows = _normalize_preview_rows(render.render_line(name, fnt)) + except Exception: + rows = ["(preview unavailable)"] + prepared.append({"index": idx, "name": name, "preview_rows": rows}) + + selected = next((i for i, f in enumerate(prepared) if f["index"] == default), 0) + if not sys.stdin.isatty(): + selected_index = prepared[selected]["index"] + config.set_font_selection(font_index=selected_index) + render.clear_font_cache() + print(f" {G_DIM}> using face {selected_index}{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 = prepared[selected]["index"] + + config.set_font_selection(font_index=selected) + render.clear_font_cache() + print(f" {G_DIM}> using face {selected}{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 +203,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..2944f02 100644 --- a/engine/config.py +++ b/engine/config.py @@ -4,6 +4,35 @@ Configuration constants, CLI flags, and glyph tables. import sys +from pathlib import Path + + +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) + repo_root = Path(__file__).resolve().parent.parent + return str((repo_root / p).resolve()) # ─── RUNTIME ────────────────────────────────────────────── HEADLINE_LIMIT = 1000 FEED_TIMEOUT = 10 @@ -17,7 +46,12 @@ 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_PATH = _resolve_font_path( + _arg_value('--font-file') + or "/Users/genejohnson/Documents/CS Bishop Drawn/CSBishopDrawn-Italic.otf" +) +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 +67,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..105646d 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,51 @@ 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 + 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."""