Locked: rules/ (deck cards + mechanics), tools/ (draw, roll, run) Unlocked: session/ (character, world, tweaks, log) Entry: run.sh launches the Textual TUI
242 lines
6.4 KiB
Python
Executable File
242 lines
6.4 KiB
Python
Executable File
#!/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()
|