feat: Implement an interactive font face picker at startup, allowing selection of specific font faces from a font file.
This commit is contained in:
167
engine/app.py
167
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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user