""" Configuration constants, CLI flags, and glyph tables. Supports both global constants (backward compatible) and injected config for testing. """ import sys from dataclasses import dataclass, field from pathlib import Path _REPO_ROOT = Path(__file__).resolve().parent.parent _FONT_EXTENSIONS = {".otf", ".ttf", ".ttc"} def _arg_value(flag, argv: list[str] | None = None): """Get value following a CLI flag, if present.""" argv = argv or sys.argv if flag not in argv: return None i = argv.index(flag) return argv[i + 1] if i + 1 < len(argv) else None def _arg_int(flag, default, argv: list[str] | None = None): """Get int CLI argument with safe fallback.""" raw = _arg_value(flag, argv) 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) return str((_REPO_ROOT / p).resolve()) def _list_font_files(font_dir): """List supported font files within a font directory.""" font_root = Path(font_dir) if not font_root.exists() or not font_root.is_dir(): return [] return [ str(path.resolve()) for path in sorted(font_root.iterdir()) if path.is_file() and path.suffix.lower() in _FONT_EXTENSIONS ] def list_repo_font_files(): """Public helper for discovering repository font files.""" return _list_font_files(FONT_DIR) def _get_platform_font_paths() -> dict[str, str]: """Get platform-appropriate font paths for non-Latin scripts.""" import platform system = platform.system() if system == "Darwin": return { "zh-cn": "/System/Library/Fonts/STHeiti Medium.ttc", "ja": "/System/Library/Fonts/ヒラギノ角ゴシック W9.ttc", "ko": "/System/Library/Fonts/AppleSDGothicNeo.ttc", "ru": "/System/Library/Fonts/Supplemental/Arial.ttf", "uk": "/System/Library/Fonts/Supplemental/Arial.ttf", "el": "/System/Library/Fonts/Supplemental/Arial.ttf", "he": "/System/Library/Fonts/Supplemental/Arial.ttf", "ar": "/System/Library/Fonts/GeezaPro.ttc", "fa": "/System/Library/Fonts/GeezaPro.ttc", "hi": "/System/Library/Fonts/Kohinoor.ttc", "th": "/System/Library/Fonts/ThonburiUI.ttc", } elif system == "Linux": return { "zh-cn": "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", "ja": "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", "ko": "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", "ru": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "uk": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "el": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "he": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "ar": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "fa": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "hi": "/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf", "th": "/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf", } else: return {} @dataclass(frozen=True) class Config: """Immutable configuration container for injected config.""" headline_limit: int = 1000 feed_timeout: int = 10 mic_threshold_db: int = 50 mode: str = "news" firehose: bool = False ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json" ntfy_cc_cmd_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json" ntfy_cc_resp_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json" ntfy_reconnect_delay: int = 5 message_display_secs: int = 30 font_dir: str = "fonts" font_path: str = "" font_index: int = 0 font_picker: bool = True font_sz: int = 60 render_h: int = 8 ssaa: int = 4 scroll_dur: float = 5.625 frame_dt: float = 0.05 firehose_h: int = 12 grad_speed: float = 0.08 glitch_glyphs: str = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋" kata_glyphs: str = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ" script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths) display: str = "pygame" websocket: bool = False websocket_port: int = 8765 theme: str = "green" @classmethod def from_args(cls, argv: list[str] | None = None) -> "Config": """Create Config from CLI arguments (or custom argv for testing).""" argv = argv or sys.argv font_dir = _resolve_font_path(_arg_value("--font-dir", argv) or "fonts") font_file_arg = _arg_value("--font-file", argv) font_files = _list_font_files(font_dir) font_path = ( _resolve_font_path(font_file_arg) if font_file_arg else (font_files[0] if font_files else "") ) return cls( headline_limit=1000, feed_timeout=10, mic_threshold_db=50, mode="poetry" if "--poetry" in argv or "-p" in argv else "news", firehose="--firehose" in argv, ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json", ntfy_cc_cmd_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json", ntfy_cc_resp_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json", ntfy_reconnect_delay=5, message_display_secs=30, font_dir=font_dir, font_path=font_path, font_index=max(0, _arg_int("--font-index", 0, argv)), font_picker="--no-font-picker" not in argv, font_sz=60, render_h=8, ssaa=4, scroll_dur=5.625, frame_dt=0.05, firehose_h=12, grad_speed=0.08, glitch_glyphs="░▒▓█▌▐╌╍╎╏┃┆┇┊┋", kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ", script_fonts=_get_platform_font_paths(), display=_arg_value("--display", argv) or "terminal", websocket="--websocket" in argv, websocket_port=_arg_int("--websocket-port", 8765, argv), theme=_arg_value("--theme", argv) or "green", ) _config: Config | None = None def get_config() -> Config: """Get the global config instance (lazy-loaded).""" global _config if _config is None: _config = Config.from_args() return _config def set_config(config: Config) -> None: """Set the global config instance (for testing).""" global _config _config = config # ─── RUNTIME ────────────────────────────────────────────── HEADLINE_LIMIT = 1000 FEED_TIMEOUT = 10 MIC_THRESHOLD_DB = 50 # dB above which glitches intensify MODE = "poetry" if "--poetry" in sys.argv or "-p" in sys.argv else "news" FIREHOSE = "--firehose" in sys.argv # ─── NTFY MESSAGE QUEUE ────────────────────────────────── NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json" NTFY_CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json" NTFY_CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json" NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen # ─── FONT RENDERING ────────────────────────────────────── FONT_DIR = _resolve_font_path(_arg_value("--font-dir") or "fonts") _FONT_FILE_ARG = _arg_value("--font-file") _FONT_FILES = _list_font_files(FONT_DIR) FONT_PATH = ( _resolve_font_path(_FONT_FILE_ARG) if _FONT_FILE_ARG else (_FONT_FILES[0] if _FONT_FILES else "") ) 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 # ─── FONT RENDERING (ADVANCED) ──────────────────────────── SSAA = 4 # super-sampling factor: render at SSAA× then downsample # ─── SCROLL / FRAME ────────────────────────────────────── SCROLL_DUR = 5.625 # seconds per headline (2/3 original speed) FRAME_DT = 0.05 # 50ms base frame rate (20 FPS) FIREHOSE_H = 12 # firehose zone height (terminal rows) GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full sweep) # ─── GLYPHS ─────────────────────────────────────────────── GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋" KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ" # ─── WEBSOCKET ───────────────────────────────────────────── DISPLAY = _arg_value("--display", sys.argv) or "pygame" WEBSOCKET = "--websocket" in sys.argv WEBSOCKET_PORT = _arg_int("--websocket-port", 8765) # ─── DEMO MODE ──────────────────────────────────────────── DEMO = "--demo" in sys.argv DEMO_EFFECT_DURATION = 5.0 # seconds per effect PIPELINE_DEMO = "--pipeline-demo" in sys.argv # ─── THEME MANAGEMENT ───────────────────────────────────────── ACTIVE_THEME = None def set_active_theme(theme_id: str = "green"): """Set the active theme by ID. Args: theme_id: Theme identifier from theme registry (e.g., "green", "orange", "purple") Raises: KeyError: If theme_id is not in the theme registry Side Effects: Sets the ACTIVE_THEME global variable """ global ACTIVE_THEME from engine import themes ACTIVE_THEME = themes.get_theme(theme_id) # Initialize theme on module load (lazy to avoid circular dependency) def _init_theme(): theme_id = _arg_value("--theme", sys.argv) or "green" try: set_active_theme(theme_id) except KeyError: pass # Theme not found, keep None _init_theme() # ─── PIPELINE MODE (new unified architecture) ───────────── PIPELINE_MODE = "--pipeline" in sys.argv PIPELINE_PRESET = _arg_value("--pipeline-preset", sys.argv) or "demo" # ─── PRESET MODE ──────────────────────────────────────────── PRESET = _arg_value("--preset", sys.argv) # ─── PIPELINE DIAGRAM ──────────────────────────────────── PIPELINE_DIAGRAM = "--pipeline-diagram" in sys.argv # ─── THEME ────────────────────────────────────────────────── THEME = _arg_value("--theme", sys.argv) or "green" 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))