#!/usr/bin/env python3 """ run.py — The Chaos TTRPG Session Client Layout: banner top | log (main) + character (right) | input bottom. Music: polls session/ambience.md, crossfades via pygame.mixer. """ import os import random import sys from datetime import date from pathlib import Path from textual import on from textual.app import App, ComposeResult from textual.containers import Horizontal, Vertical from textual.reactive import reactive from textual.widgets import Input, Static # ── Optional pygame ─────────────────────────────────────── try: import pygame HAS_PYGAME = True except ImportError: HAS_PYGAME = False # ── Paths ──────────────────────────────────────────────── BASE = Path(__file__).resolve().parent.parent SESSION = BASE / 'session' LOG_DIR = SESSION / 'log' CHAR_PATH = SESSION / 'character.md' WORLD_PATH = SESSION / 'world.md' JOURNAL_PATH = SESSION / 'journal.md' AMBIENCE_PATH = SESSION / 'ambience.md' AMBIENCE_OPTIONS_PATH = SESSION / 'ambience_options.md' AUDIO_DIR = SESSION / 'audio' TODAY = date.today().isoformat() LOG_PATH = LOG_DIR / f'{TODAY}.md' REFRESH_SECS = 2 # ── Helpers ────────────────────────────────────────────── def ensure_log(): LOG_DIR.mkdir(parents=True, exist_ok=True) if not LOG_PATH.exists(): LOG_PATH.write_text(f"# Session Log — {TODAY}\n\n") def append_log(text): with open(LOG_PATH, 'a') as f: f.write(f"- {text}\n") def read_todo(): if not JOURNAL_PATH.exists(): return ["—— No journal yet ——"] lines = JOURNAL_PATH.read_text().splitlines() in_todo = False todo = [] for l in lines: if l.strip().lstrip('#').strip().startswith('TODO'): in_todo = True continue if l.strip().startswith('#') and in_todo: break if in_todo and l.strip(): todo.append(l.strip().lstrip('- ')) return todo or ["—— All done! ——"] def read_log_tail(n=200): if not LOG_PATH.exists(): return [] lines = LOG_PATH.read_text().splitlines() return [l for l in lines if l.strip() and not l.startswith('#')][-n:] def read_char_sheet(): if not CHAR_PATH.exists(): return ["—— No character yet ——"] lines = CHAR_PATH.read_text().splitlines() out = [] for l in lines: s = l.rstrip() if s.startswith('**') and ':' in s: out.append(s.strip('*').strip()) elif s.startswith('- **'): out.append(s.lstrip('- ').strip('*').strip()) return out or ["—— No character yet ——"] # ── Status summary ─────────────────────────────────────── def status_summary(): if not CHAR_PATH.exists(): return "no character" lines = CHAR_PATH.read_text().splitlines() name = "?" health = "?" for l in lines: if l.startswith('**Name:**'): name = l.split(':', 1)[1].strip().strip('_').strip('*') if l.startswith('**Current Health:**'): h = l.split(':', 1)[1].strip().strip('_').strip('*') if h: health = h if l.startswith('**Max Health:**'): m = l.split(':', 1)[1].strip().strip('_').strip('*') if m and health == '?': health = m return f"{name} ❤ {health}" # ── Log line count ─────────────────────────────────────── def log_count(): return len(read_log_tail()) # ── Ambience subsystem ─────────────────────────────────── def parse_ambience_options(): """Parse ambience_options.md into {name: [filepath, ...]}""" if not AMBIENCE_OPTIONS_PATH.exists(): return {} options = {} lines = AMBIENCE_OPTIONS_PATH.read_text().splitlines() in_table = False for line in lines: s = line.strip() if not s.startswith('|') or not s.endswith('|'): in_table = False continue parts = [p.strip() for p in s.split('|')] parts = [p for p in parts if p] if len(parts) < 2: continue if not in_table: in_table = True continue if all(c in '-:| ' for c in s): continue name = parts[0].lower() files = [f.strip() for f in parts[1].split(',') if f.strip()] paths = [AUDIO_DIR / f for f in files] options[name] = paths return options class AmbiencePlayer: """Monitors ambience.md and crossfades background music.""" def __init__(self): self.current_ambience = 'silence' self._enabled = False self._last_mtime = 0 self._options = {} self._init_pygame() self.load_options() def _init_pygame(self): if not HAS_PYGAME: return try: os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = '1' pygame.mixer.init(frequency=44100, size=-16, channels=2, buffer=512) self._enabled = True except Exception: self._enabled = False @property def available(self): return self._enabled @property def ambience_name(self): return self.current_ambience def load_options(self): self._options = parse_ambience_options() def poll(self): if not self._enabled: return try: mtime = os.path.getmtime(AMBIENCE_PATH) except OSError: return if mtime == self._last_mtime: return self._last_mtime = mtime try: name = AMBIENCE_PATH.read_text().strip().lower() except OSError: return self._switch_to(name) def _switch_to(self, name): if name == self.current_ambience: return self.current_ambience = name if name == 'silence' or name not in self._options: if pygame.mixer.music.get_busy(): pygame.mixer.music.fadeout(2000) return tracks = self._options.get(name, []) # filter to existing files valid = [t for t in tracks if t.exists()] if not valid: return track = random.choice(valid) if pygame.mixer.music.get_busy(): pygame.mixer.music.fadeout(2000) try: pygame.mixer.music.load(str(track)) pygame.mixer.music.set_volume(0.4) pygame.mixer.music.play(loops=-1, fade_ms=2000) except pygame.error: self.current_ambience = None # ── Auto-refreshing panels ─────────────────────────────── class AutoStatic(Static): """A Static that reloads its content on an interval.""" def load(self): raise NotImplementedError def on_mount(self): self.load() self.set_interval(REFRESH_SECS, self.load) class TodoPane(AutoStatic): def load(self): items = read_todo() self.update("\n".join(f" ☐ {i}" for i in items)) class TranscriptPane(AutoStatic): def load(self): lines = read_log_tail() self.update("\n".join(lines[-80:])) class CharPane(AutoStatic): def load(self): lines = read_char_sheet() self.update("\n".join(f" {l}" for l in lines)) class StatusBar(AutoStatic): def load(self): char = status_summary() count = log_count() todo = len(read_todo()) music = "" if app_ambience_player and app_ambience_player.available: name = app_ambience_player.ambience_name music = f" │ ♫ {name}" self.update(f"{char} │ {count} entries │ {todo} todo │ {TODAY}{music}") # module-level ref so StatusBar can reach it app_ambience_player = None # ── The App ────────────────────────────────────────────── class ChaosTUI(App): TITLE = "The Chaos" CSS = """ Screen { background: #121212; } #banner { dock: top; height: 1; background: #2a2a2a; color: #e0ad4c; text-align: center; } #input-row { dock: bottom; height: 3; background: #252525; padding: 0 0; border-top: solid #3a3a3a; } Input { background: #1e1e1e; color: #e0e0e0; border: none; margin: 1 1; } Input:focus { border: none; } #main { height: 100%; } #log-col { border-right: solid #3a3a3a; background: #111111; } #todo-header { background: #3a2a1a; color: #e0b060; text-style: bold; padding: 0 1; height: 1; } #todo-content { background: #1a1510; color: #d0b080; padding: 0 1; height: 5; max-height: 5; } #log-header { background: #1d2d1d; color: #7dcd7d; text-style: bold; padding: 0 1; height: 1; } #transcript { padding: 0 1; color: #c8c8c8; } #sidebar { width: 36; min-width: 28; background: #181818; } #side-header { background: #2d2d3a; color: #b0a0e0; text-style: bold; padding: 0 1; height: 1; } #char-content { padding: 0 1; color: #c0c0c0; } #status-bar { background: #222222; color: #888888; padding: 0 1; height: 1; text-style: italic; } """ BINDINGS = [ ("ctrl+c", "quit", "Quit"), ("escape", "quit", "Quit"), ] def __init__(self, *args, no_music=False, **kwargs): super().__init__(*args, **kwargs) global app_ambience_player if not no_music: app_ambience_player = AmbiencePlayer() else: app_ambience_player = None def compose(self): yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner") with Horizontal(id="main"): with Vertical(id="log-col"): yield Static("TODO", id="todo-header") yield TodoPane(id="todo-content") yield Static("LOG", id="log-header") yield TranscriptPane(id="transcript") with Vertical(id="sidebar"): yield Static("CHARACTER", id="side-header") yield CharPane(id="char-content") yield StatusBar(id="status-bar") with Horizontal(id="input-row"): self.input = Input(placeholder=" What do you do? (just type)", id="input") yield self.input def on_mount(self): ensure_log() self.input.focus() # start ambience polling self.set_interval(REFRESH_SECS, self._check_ambience) def _check_ambience(self): if app_ambience_player: app_ambience_player.poll() @on(Input.Submitted, "#input") def on_input(self, event: Input.Submitted): text = event.value.strip() if text: append_log(text) self.input.clear() self.query_one(TodoPane).load() self.query_one(TranscriptPane).load() self.query_one(StatusBar).load() def main(): import argparse parser = argparse.ArgumentParser(description="The Chaos TUI") parser.add_argument('--no-music', action='store_true', help='Disable ambience music') args = parser.parse_args() app = ChaosTUI(no_music=args.no_music) app.run() if __name__ == '__main__': main()