#!/usr/bin/env python3 """ run.py — The Chaos TTRPG Session Client Layout: banner top | log (main) + character (right) | input bottom. """ import os 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 # ── Paths ──────────────────────────────────────────────── BASE = Path(__file__).resolve().parent.parent SESSION = BASE / 'session' LOG_DIR = SESSION / 'log' CHAR_PATH = SESSION / 'character.md' WORLD_PATH = SESSION / 'world.md' 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_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()) # ── 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 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() self.update(f"{char} │ {count} entries │ {TODAY}") # ── The App ────────────────────────────────────────────── class ChaosTUI(App): TITLE = "The Chaos" CSS = """ Screen { background: #121212; } /* ── Top banner ── */ #banner { dock: top; height: 1; background: #2a2a2a; color: #e0ad4c; text-align: center; } /* ── Bottom input ── */ #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 area: log (left) + sidebar (right) ── */ #main { height: 100%; } /* Log column */ #log-col { border-right: solid #3a3a3a; background: #111111; } #log-header { background: #1d2d1d; color: #7dcd7d; text-style: bold; padding: 0 1; height: 1; } #transcript { padding: 0 1; color: #c8c8c8; } /* Sidebar */ #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 compose(self): yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner") with Horizontal(id="main"): with Vertical(id="log-col"): 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() @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(TranscriptPane).load() self.query_one(StatusBar).load() def main(): app = ChaosTUI() app.run() if __name__ == '__main__': main()