This commit implements the Sideline/Mainline split with a clean plugin architecture: ## Core Changes ### Sideline Framework (New Directory) - Created directory containing the reusable pipeline framework - Moved pipeline core, controllers, adapters, and registry to - Moved display system to - Moved effects system to - Created plugin system with security and compatibility management in - Created preset pack system with ASCII art encoding in - Added default font (Corptic) to - Added terminal ANSI constants to ### Mainline Application (Updated) - Created for Mainline stage component registration - Updated to register Mainline stages at startup - Updated as a compatibility shim re-exporting from sideline ### Terminology Consistency - : Base class for all pipeline components (sources, effects, displays, cameras) - : Base class for distributable plugin packages (was ) - : Base class for visual effects (was ) - Backward compatibility aliases maintained for existing code ## Key Features - Plugin discovery via entry points and explicit registration - Security permissions system for plugins - Compatibility management with semantic version constraints - Preset pack system for distributable configurations - Default font bundled with Sideline (Corptic.otf) ## Testing - Updated tests to register Mainline stages before discovery - All StageRegistry tests passing Note: This is a major refactoring that separates the framework (Sideline) from the application (Mainline), enabling Sideline to be used by other applications.
246 lines
7.7 KiB
Python
246 lines
7.7 KiB
Python
"""Block rendering core - Font loading, text rasterization, word-wrap, and headline assembly.
|
|
|
|
Provides PIL font-based rendering to terminal half-block characters.
|
|
"""
|
|
|
|
import random
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Optional, Tuple
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
from sideline.fonts import get_default_font_path, get_default_font_size
|
|
|
|
|
|
# ─── FONT LOADING ─────────────────────────────────────────
|
|
_FONT_OBJ = None
|
|
_FONT_OBJ_KEY = None
|
|
_FONT_CACHE = {}
|
|
|
|
|
|
def font():
|
|
"""Lazy-load the default Sideline font."""
|
|
global _FONT_OBJ, _FONT_OBJ_KEY
|
|
|
|
try:
|
|
font_path = get_default_font_path()
|
|
font_size = get_default_font_size()
|
|
except FileNotFoundError:
|
|
# Fallback to system default if Sideline font not found
|
|
return ImageFont.load_default()
|
|
|
|
key = (font_path, font_size)
|
|
if _FONT_OBJ is None or key != _FONT_OBJ_KEY:
|
|
try:
|
|
_FONT_OBJ = ImageFont.truetype(font_path, font_size)
|
|
_FONT_OBJ_KEY = key
|
|
except Exception:
|
|
# If loading fails, fall back to system default
|
|
_FONT_OBJ = ImageFont.load_default()
|
|
_FONT_OBJ_KEY = key
|
|
return _FONT_OBJ
|
|
|
|
|
|
def clear_font_cache():
|
|
"""Reset cached font objects."""
|
|
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."""
|
|
if size is None:
|
|
size = get_default_font_size()
|
|
return ImageFont.truetype(font_path, 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: Optional[str] = None):
|
|
"""Get appropriate font for a language.
|
|
|
|
Currently uses the default Sideline font for all languages.
|
|
Language-specific fonts can be added via the font cache system.
|
|
"""
|
|
if lang is None:
|
|
return font()
|
|
if lang not in _FONT_CACHE:
|
|
# Try to load language-specific font, fall back to default
|
|
try:
|
|
# Could add language-specific font logic here
|
|
_FONT_CACHE[lang] = font()
|
|
except Exception:
|
|
_FONT_CACHE[lang] = font()
|
|
return _FONT_CACHE[lang]
|
|
|
|
|
|
# ─── RASTERIZATION ────────────────────────────────────────
|
|
def render_line(text, fnt=None):
|
|
"""Render a line of text as terminal rows using OTF font + half-blocks."""
|
|
if fnt is None:
|
|
fnt = font()
|
|
bbox = fnt.getbbox(text)
|
|
if not bbox or bbox[2] <= bbox[0]:
|
|
return [""]
|
|
pad = 4
|
|
img_w = bbox[2] - bbox[0] + pad * 2
|
|
img_h = bbox[3] - bbox[1] + pad * 2
|
|
img = Image.new("L", (img_w, img_h), 0)
|
|
draw = ImageDraw.Draw(img)
|
|
draw.text((-bbox[0] + pad, -bbox[1] + pad), text, fill=255, font=fnt)
|
|
|
|
# Rendering parameters (can be made configurable)
|
|
render_h = 6 # Terminal rows per rendered line
|
|
ssaa = 2 # Supersampling anti-aliasing factor
|
|
|
|
pix_h = render_h * 2
|
|
hi_h = pix_h * ssaa
|
|
scale = hi_h / max(img_h, 1)
|
|
new_w_hi = max(1, int(img_w * scale))
|
|
img = img.resize((new_w_hi, hi_h), Image.Resampling.LANCZOS)
|
|
new_w = max(1, int(new_w_hi / ssaa))
|
|
img = img.resize((new_w, pix_h), Image.Resampling.LANCZOS)
|
|
data = img.tobytes()
|
|
thr = 80
|
|
rows = []
|
|
for y in range(0, pix_h, 2):
|
|
row = []
|
|
for x in range(new_w):
|
|
top = data[y * new_w + x] > thr
|
|
bot = data[(y + 1) * new_w + x] > thr if y + 1 < pix_h else False
|
|
if top and bot:
|
|
row.append("█")
|
|
elif top:
|
|
row.append("▀")
|
|
elif bot:
|
|
row.append("▄")
|
|
else:
|
|
row.append(" ")
|
|
rows.append("".join(row))
|
|
return rows
|
|
|
|
|
|
def big_wrap(text: str, width: int, fnt=None) -> list[str]:
|
|
"""Wrap text and render to big block characters."""
|
|
if fnt is None:
|
|
fnt = font()
|
|
text = re.sub(r"\s+", " ", text.upper())
|
|
words = text.split()
|
|
lines = []
|
|
cur = ""
|
|
|
|
# Get font size for height calculation
|
|
try:
|
|
font_size = fnt.size if hasattr(fnt, "size") else get_default_font_size()
|
|
except Exception:
|
|
font_size = get_default_font_size()
|
|
|
|
render_h = 6 # Terminal rows per rendered line
|
|
|
|
for word in words:
|
|
test = f"{cur} {word}".strip() if cur else word
|
|
bbox = fnt.getbbox(test)
|
|
if bbox:
|
|
img_h = bbox[3] - bbox[1] + 8
|
|
pix_h = render_h * 2
|
|
scale = pix_h / max(img_h, 1)
|
|
term_w = int((bbox[2] - bbox[0] + 8) * scale)
|
|
else:
|
|
term_w = 0
|
|
max_term_w = width - 4 - 4
|
|
if term_w > max_term_w and cur:
|
|
lines.append(cur)
|
|
cur = word
|
|
else:
|
|
cur = test
|
|
if cur:
|
|
lines.append(cur)
|
|
out = []
|
|
for i, ln in enumerate(lines):
|
|
out.extend(render_line(ln, fnt))
|
|
if i < len(lines) - 1:
|
|
out.append("")
|
|
return out
|
|
|
|
|
|
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
|
|
def make_block(title: str, src: str, ts: str, w: int) -> Tuple[list[str], str, int]:
|
|
"""Render a headline into a content block with color.
|
|
|
|
Args:
|
|
title: Headline text to render
|
|
src: Source identifier (for metadata)
|
|
ts: Timestamp string (for metadata)
|
|
w: Width constraint in terminal characters
|
|
|
|
Returns:
|
|
tuple: (content_lines, color_code, meta_row_index)
|
|
- content_lines: List of rendered text lines
|
|
- color_code: ANSI color code for display
|
|
- meta_row_index: Row index of metadata line
|
|
"""
|
|
# Use default font for all languages (simplified from original)
|
|
lang_font = font()
|
|
|
|
# Simple uppercase conversion (can be made language-aware later)
|
|
title_up = re.sub(r"\s+", " ", title.upper())
|
|
|
|
# Standardize quotes and dashes
|
|
for old, new in [
|
|
("\u2019", "'"),
|
|
("\u2018", "'"),
|
|
("\u201c", '"'),
|
|
("\u201d", '"'),
|
|
("\u2013", "-"),
|
|
("\u2014", "-"),
|
|
]:
|
|
title_up = title_up.replace(old, new)
|
|
|
|
big_rows = big_wrap(title_up, w - 4, lang_font)
|
|
|
|
# Matrix-style color selection
|
|
hc = random.choice(
|
|
[
|
|
"\033[38;5;46m", # matrix green
|
|
"\033[38;5;34m", # dark green
|
|
"\033[38;5;82m", # lime
|
|
"\033[38;5;48m", # sea green
|
|
"\033[38;5;37m", # teal
|
|
"\033[38;5;44m", # cyan
|
|
"\033[38;5;87m", # sky
|
|
"\033[38;5;117m", # ice blue
|
|
"\033[38;5;250m", # cool white
|
|
"\033[38;5;156m", # pale green
|
|
"\033[38;5;120m", # mint
|
|
"\033[38;5;80m", # dark cyan
|
|
"\033[38;5;108m", # grey-green
|
|
"\033[38;5;115m", # sage
|
|
"\033[1;38;5;46m", # bold green
|
|
"\033[1;38;5;250m", # bold white
|
|
]
|
|
)
|
|
|
|
content = [" " + r for r in big_rows]
|
|
content.append("")
|
|
meta = f"\u2591 {src} \u00b7 {ts}"
|
|
content.append(" " * max(2, w - len(meta) - 2) + meta)
|
|
return content, hc, len(content) - 1 # (rows, color, meta_row_index)
|