#!/usr/bin/env python3 """ run.py — The Chaos TTRPG Session Client Layout: banner | TODO | CHAR/LOG (tabs) | TURN (desc + prompt) | status | input. Music: polls session/ambience.md, plays via miniaudio. """ 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, VerticalScroll from textual.widgets import Input, Static, TabbedContent, TabPane from rich.markdown import Markdown as RichMarkdown # ── Optional miniaudio ──────────────────────────────────── try: import miniaudio HAS_AUDIO = True except ImportError: HAS_AUDIO = False print("Note: miniaudio not installed — no ambience music. Install with: pip install miniaudio", file=sys.stderr) # ── 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' TURN_DESC_PATH = SESSION / 'turn_description.md' TURN_PROMPT_PATH = SESSION / 'turn_prompt.md' TURN_REACTION_PATH = SESSION / 'turn_reaction.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") _populate_if_empty() def _populate_if_empty(): content = LOG_PATH.read_text().strip() if content and len(content.splitlines()) > 2: return prev = _previous_log() if prev: lines = prev.read_text().splitlines() lines[0] = f"# Session Log — {TODAY}" LOG_PATH.write_text('\n'.join(lines) + '\n') def _previous_log(): entries = sorted(LOG_DIR.glob('*.md')) today_name = LOG_PATH.name for e in reversed(entries): if e.name != today_name: return e return None 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_turn_description(): if not TURN_DESC_PATH.exists(): return "" return TURN_DESC_PATH.read_text().strip() def read_turn_prompt(): if not TURN_PROMPT_PATH.exists(): return "" return TURN_PROMPT_PATH.read_text().strip() def write_reaction(text): TURN_REACTION_PATH.write_text(text + '\n') # ── 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}" 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 plays background music via miniaudio.""" def __init__(self): self.current_ambience = 'silence' self._last_mtime = 0 self._options = {} self._device = None self._stream = None self.load_options() @property def available(self): return HAS_AUDIO @property def ambience_name(self): return self.current_ambience def load_options(self): self._options = parse_ambience_options() def _stop(self): if self._device: try: self._device.close() except Exception: pass self._device = None self._stream = None def poll(self): if not HAS_AUDIO: 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 self._stop() if name == 'silence' or name not in self._options: return tracks = self._options.get(name, []) valid = [t for t in tracks if t.exists()] if not valid: return track = random.choice(valid) try: self._stream = miniaudio.stream_file(str(track)) self._device = miniaudio.PlaybackDevice() self._device.start(self._stream) except Exception: 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() display = "\n".join(lines[-80:]) if lines: display += "\n >>--- NOW --->" self.update(display) self.call_after_refresh(self._scroll_bottom) def _scroll_bottom(self): if self.parent and hasattr(self.parent, 'scroll_end'): self.parent.scroll_end(animate=False) class CharPane(AutoStatic): def load(self): if not CHAR_PATH.exists(): self.update("*No character sheet*") return self.update(RichMarkdown(CHAR_PATH.read_text().strip())) class StatusBar(AutoStatic): def load(self): char = status_summary() count = log_count() todo = len(read_todo()) music = "" if not HAS_AUDIO: music = " │ ♫ (install miniaudio)" elif app_ambience_player: name = app_ambience_player.ambience_name music = f" │ ♫ {name}" self.update(f"{char} │ {count} entries │ {todo} todo │ {TODAY}{music}") class TurnPane(AutoStatic): def load(self): desc = read_turn_description() prompt = read_turn_prompt() parts = [] if desc: parts.append(desc) if prompt: parts.append(f"---\n\n*{prompt}*") content = "\n\n".join(parts) if parts else "*The world waits.*" self.update(RichMarkdown(content)) # 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%; 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; overflow-y: auto; } #middle-tabs { height: 25%; min-height: 8; } TabbedContent { background: #1a1a2a; } VerticalScroll { overflow-y: auto; scrollbar-size-vertical: 2; scrollbar-color: #555555; scrollbar-color-hover: #777777; scrollbar-color-active: #999999; } #char-content { background: #1e1e2a; color: #c0c0c0; padding: 0 1; } #transcript { background: #1a2a1a; color: #c8c8c8; padding: 0 1; } #turn-header { background: #2d2d2d; color: #e0c080; text-style: bold; padding: 0 1; height: 1; } #turn-scroll { height: 1fr; } #turn-content { background: #161616; color: #d8d8d8; padding: 0 2; } #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 Vertical(id="main"): yield Static("TODO", id="todo-header") yield TodoPane(id="todo-content") with TabbedContent(initial="char-tab", id="middle-tabs"): with TabPane("CHARACTER", id="char-tab"): with VerticalScroll(): yield CharPane(id="char-content") with TabPane("LOG", id="log-tab"): with VerticalScroll(): yield TranscriptPane(id="transcript") yield Static("TURN", id="turn-header") with VerticalScroll(id="turn-scroll"): yield TurnPane(id="turn-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() if not TURN_REACTION_PATH.exists(): TURN_REACTION_PATH.write_text('') self.input.focus() 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: write_reaction(text) self.input.clear() self.query_one(TodoPane).load() self.query_one(CharPane).load() self.query_one(TranscriptPane).load() self.query_one(TurnPane).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()