546 lines
16 KiB
Python
Executable File
546 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
run.py — The Chaos TTRPG Session Client
|
|
|
|
Layout: banner | TODO | CHAR/LOG (tabs) | BOOK (paged) | status.
|
|
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 Button, 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'
|
|
BOOK_PATH = SESSION / 'book.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:]
|
|
|
|
|
|
# ── 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())
|
|
|
|
|
|
# ── Book helpers ─────────────────────────────────────────
|
|
def load_book_pages():
|
|
if not BOOK_PATH.exists() or not BOOK_PATH.read_text().strip():
|
|
return ["*The story has not begun.*"]
|
|
text = BOOK_PATH.read_text().strip()
|
|
turns = text.split('\n## ')
|
|
pages = []
|
|
for i, t in enumerate(turns):
|
|
pages.append(t if i == 0 else '## ' + t)
|
|
return pages if pages else ["*The story has not begun.*"]
|
|
|
|
|
|
# ── 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}")
|
|
|
|
|
|
# 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;
|
|
}
|
|
|
|
#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;
|
|
scrollbar-size-vertical: 2;
|
|
}
|
|
|
|
#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;
|
|
}
|
|
|
|
#book-section {
|
|
height: 1fr;
|
|
layout: vertical;
|
|
}
|
|
#book-header {
|
|
background: #2d2d2d;
|
|
color: #e0c080;
|
|
text-style: bold;
|
|
padding: 0 1;
|
|
height: 1;
|
|
}
|
|
#book-nav {
|
|
height: 3;
|
|
background: #222222;
|
|
align: center middle;
|
|
}
|
|
#book-nav Button {
|
|
width: 10;
|
|
margin: 0 1;
|
|
}
|
|
#book-nav Button:disabled {
|
|
color: #444444;
|
|
}
|
|
#book-nav Button:hover {
|
|
text-style: bold;
|
|
}
|
|
#book-nav-center {
|
|
height: 3;
|
|
width: 1fr;
|
|
}
|
|
#book-page-label {
|
|
height: 1;
|
|
color: #c0b090;
|
|
text-style: bold;
|
|
padding: 0 2;
|
|
text-align: center;
|
|
}
|
|
#book-hint {
|
|
height: 1;
|
|
color: #808080;
|
|
padding: 0 2;
|
|
text-align: center;
|
|
}
|
|
#book-progress {
|
|
height: 1;
|
|
background: #1a1a1a;
|
|
color: #e0b060;
|
|
padding: 0 2;
|
|
text-align: center;
|
|
}
|
|
#book-scroll {
|
|
height: 1fr;
|
|
}
|
|
#book-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"),
|
|
("h", "prev_page", "Previous turn"),
|
|
("l", "next_page", "Next turn"),
|
|
("[", "prev_page", "Previous turn"),
|
|
("]", "next_page", "Next turn"),
|
|
("j", "skip_fwd", "Skip 5 ahead"),
|
|
("k", "skip_bwd", "Skip 5 back"),
|
|
("g", "first_page", "First page"),
|
|
("G", "last_page", "Last page"),
|
|
]
|
|
|
|
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
|
|
self._book_page = 0
|
|
self._book_pages = []
|
|
|
|
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("BOOK", id="book-header")
|
|
with Vertical(id="book-section"):
|
|
with Horizontal(id="book-nav"):
|
|
yield Button("<< Prev", id="book-prev")
|
|
with Vertical(id="book-nav-center"):
|
|
yield Static("", id="book-page-label")
|
|
yield Static("", id="book-hint")
|
|
yield Static("", id="book-progress")
|
|
yield Button("Next >>", id="book-next")
|
|
with VerticalScroll(id="book-scroll"):
|
|
yield Static("", id="book-content")
|
|
yield StatusBar(id="status-bar")
|
|
|
|
def on_mount(self):
|
|
ensure_log()
|
|
self._reload_book()
|
|
self._book_page = len(self._book_pages) - 1
|
|
self._render_book_page()
|
|
self.set_interval(REFRESH_SECS, self._check_ambience)
|
|
self.set_interval(REFRESH_SECS, self._reload_book)
|
|
|
|
def _check_ambience(self):
|
|
if app_ambience_player:
|
|
app_ambience_player.poll()
|
|
|
|
def _reload_book(self):
|
|
self._book_pages = load_book_pages()
|
|
self._book_page = max(0, min(self._book_page, len(self._book_pages) - 1))
|
|
self._render_book_page()
|
|
|
|
def _render_book_page(self):
|
|
self.query_one("#book-content").update(RichMarkdown(self._book_pages[self._book_page]))
|
|
total = len(self._book_pages)
|
|
self.query_one("#book-page-label").update(f"Page {self._book_page + 1} of {total}")
|
|
self.query_one("#book-hint").update("h prev | l next | j +5 | k -5 | g first | G last")
|
|
pct = (self._book_page + 1) / total if total else 1
|
|
fill = round(pct * 20)
|
|
bar = "█" * fill + "░" * (20 - fill)
|
|
self.query_one("#book-progress").update(f"[{bar}]")
|
|
self.query_one("#book-prev").disabled = (self._book_page == 0)
|
|
self.query_one("#book-next").disabled = (self._book_page == total - 1)
|
|
|
|
def action_prev_page(self):
|
|
if self._book_page > 0:
|
|
self._book_page -= 1
|
|
self._render_book_page()
|
|
self.query_one("#book-scroll").scroll_home(animate=False)
|
|
|
|
def action_next_page(self):
|
|
if self._book_page < len(self._book_pages) - 1:
|
|
self._book_page += 1
|
|
self._render_book_page()
|
|
self.query_one("#book-scroll").scroll_home(animate=False)
|
|
|
|
def action_skip_fwd(self):
|
|
self._book_page = min(len(self._book_pages) - 1, self._book_page + 5)
|
|
self._render_book_page()
|
|
self.query_one("#book-scroll").scroll_home(animate=False)
|
|
|
|
def action_skip_bwd(self):
|
|
self._book_page = max(0, self._book_page - 5)
|
|
self._render_book_page()
|
|
self.query_one("#book-scroll").scroll_home(animate=False)
|
|
|
|
def action_first_page(self):
|
|
self._book_page = 0
|
|
self._render_book_page()
|
|
self.query_one("#book-scroll").scroll_home(animate=False)
|
|
|
|
def action_last_page(self):
|
|
self._book_page = len(self._book_pages) - 1
|
|
self._render_book_page()
|
|
self.query_one("#book-scroll").scroll_home(animate=False)
|
|
|
|
@on(Button.Pressed, "#book-prev")
|
|
def on_prev(self):
|
|
self.action_prev_page()
|
|
|
|
@on(Button.Pressed, "#book-next")
|
|
def on_next(self):
|
|
self.action_next_page()
|
|
|
|
|
|
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()
|