forked from genewildish/Mainline
438 lines
13 KiB
Python
438 lines
13 KiB
Python
"""
|
|
Application orchestrator — boot sequence, signal handling, main loop wiring.
|
|
"""
|
|
|
|
import atexit
|
|
import os
|
|
import signal
|
|
import sys
|
|
import termios
|
|
import time
|
|
import tty
|
|
|
|
from engine import config, render, themes
|
|
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
|
from engine.mic import MicMonitor
|
|
from engine.ntfy import NtfyPoller
|
|
from engine.scroll import stream
|
|
from engine.terminal import (
|
|
CLR,
|
|
CURSOR_OFF,
|
|
CURSOR_ON,
|
|
G_DIM,
|
|
G_HI,
|
|
G_MID,
|
|
RST,
|
|
W_DIM,
|
|
W_GHOST,
|
|
boot_ln,
|
|
slow_print,
|
|
tw,
|
|
)
|
|
|
|
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 _draw_color_picker(themes_list, selected):
|
|
"""Draw the color theme picker menu.
|
|
|
|
Args:
|
|
themes_list: List of (theme_id, Theme) tuples from THEME_REGISTRY.items()
|
|
selected: Index of currently selected theme (0-2)
|
|
"""
|
|
print(CLR, end="")
|
|
print()
|
|
|
|
print(
|
|
f" {G_HI}▼ COLOR THEME{RST} {W_GHOST}─ ↑/↓ or j/k to move, Enter/q to select{RST}"
|
|
)
|
|
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}\n")
|
|
|
|
for i, (theme_id, theme) in enumerate(themes_list):
|
|
prefix = " ▶ " if i == selected else " "
|
|
color = G_HI if i == selected else ""
|
|
reset = "" if i == selected else W_GHOST
|
|
print(f"{prefix}{color}{theme.name}{reset}")
|
|
|
|
print()
|
|
|
|
|
|
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_color_theme():
|
|
"""Interactive color theme picker. Defaults to 'green' if not TTY.
|
|
|
|
Displays a menu of available themes and lets user select with arrow keys.
|
|
Non-interactive environments (piped stdin, CI) silently default to green.
|
|
"""
|
|
# Non-interactive fallback
|
|
if not sys.stdin.isatty():
|
|
config.set_active_theme("green")
|
|
return
|
|
|
|
# Interactive picker
|
|
themes_list = list(themes.THEME_REGISTRY.items())
|
|
selected = 0
|
|
|
|
fd = sys.stdin.fileno()
|
|
old_settings = termios.tcgetattr(fd)
|
|
try:
|
|
tty.setcbreak(fd)
|
|
while True:
|
|
_draw_color_picker(themes_list, selected)
|
|
key = _read_picker_key()
|
|
if key == "up":
|
|
selected = max(0, selected - 1)
|
|
elif key == "down":
|
|
selected = min(len(themes_list) - 1, selected + 1)
|
|
elif key == "enter":
|
|
break
|
|
elif key == "interrupt":
|
|
raise KeyboardInterrupt
|
|
finally:
|
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
|
|
selected_theme_id = themes_list[selected][0]
|
|
config.set_active_theme(selected_theme_id)
|
|
|
|
theme_name = themes_list[selected][1].name
|
|
print(f" {G_DIM}> using {theme_name}{RST}")
|
|
time.sleep(0.8)
|
|
print(CLR, end="")
|
|
print(CURSOR_OFF, end="")
|
|
print()
|
|
|
|
|
|
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))
|
|
|
|
def handle_sigint(*_):
|
|
print(f"\n\n {G_DIM}> SIGNAL LOST{RST}")
|
|
print(f" {W_GHOST}> connection terminated{RST}\n")
|
|
sys.exit(0)
|
|
|
|
signal.signal(signal.SIGINT, handle_sigint)
|
|
|
|
w = tw()
|
|
print(CLR, end="")
|
|
print(CURSOR_OFF, end="")
|
|
pick_color_theme()
|
|
pick_font_face()
|
|
w = tw()
|
|
print()
|
|
time.sleep(0.4)
|
|
|
|
for ln in TITLE:
|
|
print(f"{G_HI}{ln}{RST}")
|
|
time.sleep(0.07)
|
|
|
|
print()
|
|
_subtitle = {
|
|
"poetry": "literary consciousness stream",
|
|
"code": "source consciousness stream",
|
|
}.get(config.MODE, "digital consciousness stream")
|
|
print(f" {W_DIM}v0.1 · {_subtitle}{RST}")
|
|
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
|
print()
|
|
time.sleep(0.4)
|
|
|
|
cached = load_cache() if "--refresh" not in sys.argv else None
|
|
if cached:
|
|
items = cached
|
|
boot_ln("Cache", f"LOADED [{len(items)} SIGNALS]", True)
|
|
elif config.MODE == "poetry":
|
|
slow_print(" > INITIALIZING LITERARY CORPUS...\n")
|
|
time.sleep(0.2)
|
|
print()
|
|
items, linked, failed = fetch_poetry()
|
|
print()
|
|
print(
|
|
f" {G_DIM}>{RST} {G_MID}{linked} TEXTS LOADED{RST} {W_GHOST}· {failed} DARK{RST}"
|
|
)
|
|
print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}")
|
|
save_cache(items)
|
|
elif config.MODE == "code":
|
|
from engine.fetch_code import fetch_code
|
|
|
|
slow_print(" > INITIALIZING SOURCE ARRAY...\n")
|
|
time.sleep(0.2)
|
|
print()
|
|
items, line_count, _ = fetch_code()
|
|
print()
|
|
print(f" {G_DIM}>{RST} {G_MID}{line_count} LINES ACQUIRED{RST}")
|
|
else:
|
|
slow_print(" > INITIALIZING FEED ARRAY...\n")
|
|
time.sleep(0.2)
|
|
print()
|
|
items, linked, failed = fetch_all()
|
|
print()
|
|
print(
|
|
f" {G_DIM}>{RST} {G_MID}{linked} SOURCES LINKED{RST} {W_GHOST}· {failed} DARK{RST}"
|
|
)
|
|
print(f" {G_DIM}>{RST} {G_MID}{len(items)} SIGNALS ACQUIRED{RST}")
|
|
save_cache(items)
|
|
|
|
if not items:
|
|
print(f"\n {W_DIM}> NO SIGNAL — check network{RST}")
|
|
sys.exit(1)
|
|
|
|
print()
|
|
mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB)
|
|
mic_ok = mic.start()
|
|
if mic.available:
|
|
boot_ln(
|
|
"Microphone",
|
|
"ACTIVE"
|
|
if mic_ok
|
|
else "OFFLINE · check System Settings → Privacy → Microphone",
|
|
bool(mic_ok),
|
|
)
|
|
|
|
ntfy = NtfyPoller(
|
|
config.NTFY_TOPIC,
|
|
reconnect_delay=config.NTFY_RECONNECT_DELAY,
|
|
display_secs=config.MESSAGE_DISPLAY_SECS,
|
|
)
|
|
ntfy_ok = ntfy.start()
|
|
boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok)
|
|
|
|
if config.FIREHOSE:
|
|
boot_ln("Firehose", "ENGAGED", True)
|
|
|
|
if config.FIGMENT:
|
|
try:
|
|
from effects_plugins.figment import FigmentEffect # noqa: F401
|
|
|
|
boot_ln("Figment", f"ARMED [{config.FIGMENT_INTERVAL}s interval]", True)
|
|
except (ImportError, OSError):
|
|
boot_ln("Figment", "UNAVAILABLE — run: brew install cairo", False)
|
|
|
|
time.sleep(0.4)
|
|
slow_print(" > STREAMING...\n")
|
|
time.sleep(0.2)
|
|
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
|
print()
|
|
time.sleep(0.4)
|
|
|
|
stream(items, ntfy, mic)
|
|
|
|
print()
|
|
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
|
print(f" {G_DIM}> {config.HEADLINE_LIMIT} SIGNALS PROCESSED{RST}")
|
|
print(f" {W_GHOST}> end of stream{RST}")
|
|
print()
|