feat: Implement an interactive font face picker at startup, allowing selection of specific font faces from a font file.

This commit is contained in:
2026-03-15 03:38:14 -07:00
parent 0740e34293
commit e6826c884c
3 changed files with 250 additions and 6 deletions

View File

@@ -3,11 +3,14 @@ Application orchestrator — boot sequence, signal handling, main loop wiring.
""" """
import sys import sys
import os
import time import time
import signal import signal
import atexit import atexit
import termios
import tty
from engine import config from engine import config, render
from engine.terminal import ( from engine.terminal import (
RST, G_HI, G_MID, G_DIM, W_DIM, W_GHOST, CLR, CURSOR_OFF, CURSOR_ON, tw, RST, G_HI, G_MID, G_DIM, W_DIM, W_GHOST, CLR, CURSOR_OFF, CURSOR_ON, tw,
slow_print, boot_ln, 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(): def main():
atexit.register(lambda: print(CURSOR_ON, end="", flush=True)) atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
@@ -40,6 +203,8 @@ def main():
w = tw() w = tw()
print(CLR, end="") print(CLR, end="")
print(CURSOR_OFF, end="") print(CURSOR_OFF, end="")
pick_font_face()
w = tw()
print() print()
time.sleep(0.4) time.sleep(0.4)

View File

@@ -4,6 +4,35 @@ Configuration constants, CLI flags, and glyph tables.
import sys 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 ────────────────────────────────────────────── # ─── RUNTIME ──────────────────────────────────────────────
HEADLINE_LIMIT = 1000 HEADLINE_LIMIT = 1000
FEED_TIMEOUT = 10 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 MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
# ─── FONT RENDERING ────────────────────────────────────── # ─── 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 FONT_SZ = 60
RENDER_H = 8 # terminal rows per rendered text line 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 ─────────────────────────────────────────────── # ─── GLYPHS ───────────────────────────────────────────────
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋" GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ" 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))

View File

@@ -6,6 +6,7 @@ Depends on: config, terminal, sources, translate.
import re import re
import random import random
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
@@ -49,16 +50,51 @@ MSG_GRAD_COLS = [
# ─── FONT LOADING ───────────────────────────────────────── # ─── FONT LOADING ─────────────────────────────────────────
_FONT_OBJ = None _FONT_OBJ = None
_FONT_OBJ_KEY = None
_FONT_CACHE = {} _FONT_CACHE = {}
def font(): def font():
"""Lazy-load the primary OTF font.""" """Lazy-load the primary OTF font (path + face index aware)."""
global _FONT_OBJ global _FONT_OBJ, _FONT_OBJ_KEY
if _FONT_OBJ is None: key = (config.FONT_PATH, config.FONT_INDEX, config.FONT_SZ)
_FONT_OBJ = ImageFont.truetype(config.FONT_PATH, 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 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): def font_for_lang(lang=None):
"""Get appropriate font for a language.""" """Get appropriate font for a language."""