add ambience music system: TUI crossfade, yt-dlp fetch, source tracking

- tools/run.py: pygame.mixer subsystem — polls session/ambience.md,
  crossfades tracks, shows ♫ in status bar
- tools/music-fetch.py: search/download from YouTube via yt-dlp,
  auto-increment filenames, --replace and --dry-run modes
- tools/ambience.py: companion CLI to set ambience state
- session/ambience.md: current ambience state file (DM writes here)
- session/ambience_options.md: ambience → file mapping table
- session/ambience_sources.md: file → YouTube URL tracking for re-download
- session/audio/ added to .gitignore (audio files not tracked in git)
This commit is contained in:
Dejvino 2026-06-24 21:44:18 +02:00
parent d4a19ef438
commit a2a6b1cb26
9 changed files with 705 additions and 19 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
__pycache__/
*.pyc
.env
session/audio/

View File

@ -9,12 +9,17 @@ the-chaos/
├── rules/ # LOCKED — the game itself, do not modify
│ ├── deck/ # Card tables (souls, cook, creatures, curiosities)
│ └── mechanics.md # Core rules reference
├── tools/ # LOCKED — CLI helpers (draw.py, roll.py, run.py)
└── session/ # UNLOCKED — our campaign
├── character.md # Player character sheet
├── world.md # Keep & Realm state (NPCs, locations, threads)
├── tweaks.md # House rules log
└── log/ # Raw session logs by date
├── tools/ # LOCKED — CLI helpers (draw.py, roll.py, run.py, ambience.py)
└── session/ # UNLOCKED — our campaign
├── character.md # Player character sheet
├── world.md # Keep & Realm state (NPCs, locations, threads)
├── journal.md # TODO / DONE task tracking
├── tweaks.md # House rules log
├── ambience.md # Current ambience (written by DM, read by TUI)
├── ambience_options.md # Ambience → track file mapping
├── ambience_sources.md # Track source URLs (for re-download)
├── audio/ # Music files go here
└── log/ # Raw session logs by date
```
## First Steps (Fresh Session)
@ -55,11 +60,17 @@ Then begin narrating from where things left off.
2. **Ask "what do you do?"** — let the player drive. Never pre-decide outcomes.
3. **Draw cards when needed** — use `python3 tools/draw.py <deck> <table>` for random results
4. **Player rolls dice physically** — they report results, you narrate outcomes
5. **Log everything** — after each meaningful beat, append to `session/log/<today>.md`
6. **Update files immediately** — damage taken, loot gained, NPCs met → update `character.md` and `world.md` right away
7. **Keep tweaks.md** — if you make a house rule or add a custom table, log it in `tweaks.md`
8. **Death is real** — if the PC dies, help the player roll a new character. That's the game.
5. **Log before narrating** — After every meaningful beat (conversation, travel, roll, combat round, decision), append the beat to `session/log/<today>.md` **before** describing the next scene. The log comes first, always. Format: `- **time of day** — brief description.` Each beat gets its own line. World changes get `- *World Change:* ...` mixed into the timeline.
6. **Keep journal.md** — Add tasks to `session/journal.md` under `## TODO`. Move them to `## DONE` when completed.
7. **Update files immediately** — damage taken, loot gained, NPCs met → update `character.md` and `world.md` right away, before the next narration.
8. **Set the ambience** — When the scene's mood changes (arriving in town, entering combat, exploring a dungeon), write the ambience name into `session/ambience.md`:
```
echo "tavern" > session/ambience.md
```
The TUI polls this file and crossfades background music. Available names are listed in `session/ambience_options.md`. Use `silence` to stop music.
9. **Keep tweaks.md** — if you make a house rule or add a custom table, log it in `tweaks.md`.
10. **Death is real** — if the PC dies, help the player roll a new character. That's the game.
## The TUI
The player may have `tools/run.py` open in another terminal. It reads `session/character.md` and the log file to display a live dashboard. Keep those files accurate and it will reflect the game state.
The player may have `tools/run.py` open in another terminal. It reads `session/character.md` and the log file to display a live dashboard. Keep those files accurate and it will reflect the game state. The TUI also displays the current ambience in the status bar when music is active.

1
session/ambience.md Normal file
View File

@ -0,0 +1 @@
silence

View File

@ -0,0 +1,79 @@
# Ambience Options
Set the current ambience by writing its name into `session/ambience.md`.
The TUI polls this file and crossfades to the matching track.
Music files go in `session/audio/`. Supported formats: `.mp3`, `.ogg`, `.wav`.
When multiple files are listed, one is chosen at random each time the ambience activates.
## Requirements
```bash
pip install pygame yt-dlp
```
`ffmpeg` must also be installed on your system.
## Fetching New Tracks
Use the music-fetch tool to search YouTube and download tracks:
```bash
# Auto-search for a tavern track
python3 tools/music-fetch.py tavern
# Custom query
python3 tools/music-fetch.py "deep fen" "swamp ambience D&D"
# Specific video
python3 tools/music-fetch.py tavern --url "https://youtu.be/..."
# Replace all tracks for an ambience
python3 tools/music-fetch.py tavern --replace
# Preview without downloading
python3 tools/music-fetch.py tavern --dry-run
```
Sources are recorded in `session/ambience_sources.md` so tracks can be
re-downloaded without keeping audio files in git.
## Available Ambiences
| Ambience | Files |
|----------|-------|
| silence | (stops all music) |
| calm | calm_01.ogg |
| combat | combat_01.ogg |
| dungeon | dungeon_01.ogg, dungeon_02.ogg |
| forest | forest_01.ogg, forest_02.ogg |
| tavern | tavern_01.ogg |
| tension | tension_01.ogg |
| town | town_01.ogg |
| wilds | wilds_01.ogg |
## Usage (DM)
```bash
# Switch to forest ambience
echo "forest" > session/ambience.md
# Stop music
echo "silence" > session/ambience.md
```
Or use the companion CLI shortcut:
```bash
python3 tools/ambience.py forest
python3 tools/ambience.py silence
```
## Status Display
The TUI status bar shows the current ambience:
```
Dillion ❤ 10 │ 42 entries │ 3 todo │ 2026-06-24 │ ♫ tavern
```
If pygame is not installed, the music icon is absent and no audio plays.

View File

@ -0,0 +1,7 @@
# Ambience Sources
Each entry maps a local audio file to its source URL so tracks can be
re-downloaded without keeping the audio files in git.
| File | Ambience | Source |
|------|----------|--------|

View File

@ -4,4 +4,6 @@ _Any house rules, custom tables, or modifications we've made._
| Date | Change |
|------|--------|
| | |
| 2026-06-23 | AGENTS.md rule #5 strengthened: "Log before narrating, not after." Log now gets written before the next scene description. |
| 2026-06-23 | Created journal.md with TODO/DONE sections. Added TODO pane to run.py TUI (above the log window). |
| 2026-06-24 | Added ambience music subsystem to run.py — polls `session/ambience.md`, crossfades via pygame.mixer. Created companion CLI `tools/ambience.py`, config file `session/ambience_options.md`, and `session/audio/` directory. |

44
tools/ambience.py Executable file
View File

@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Companion CLI to set ambience for the TUI.
Usage:
python3 tools/ambience.py <name>
python3 tools/ambience.py silence
"""
import sys
from pathlib import Path
SESSION = Path(__file__).resolve().parent.parent / 'session'
AMBIENCE_PATH = SESSION / 'ambience.md'
def main():
if len(sys.argv) < 2:
names = _available()
print(f"Usage: python3 tools/ambience.py <name>")
print(f"Available: {', '.join(names)}")
sys.exit(1)
name = sys.argv[1].lower()
AMBIENCE_PATH.write_text(name + '\n')
print(f"♫ ambience set to: {name}")
def _available():
path = SESSION / 'ambience_options.md'
if not path.exists():
return ['silence']
names = ['silence']
for line in path.read_text().splitlines():
s = line.strip()
if s.startswith('|') and s.endswith('|'):
parts = [p.strip() for p in s.split('|')]
parts = [p for p in parts if p]
if parts and parts[0].lower() not in ('ambience', '---'):
names.append(parts[0])
return names
if __name__ == '__main__':
main()

367
tools/music-fetch.py Executable file
View File

@ -0,0 +1,367 @@
#!/usr/bin/env python3
"""
tools/music-fetch.py Search YouTube and download ambience music.
Wires downloaded tracks into session/ambience_options.md.
Requires:
pip install yt-dlp
ffmpeg (system install, for audio extraction)
Usage:
python3 tools/music-fetch.py tavern
python3 tools/music-fetch.py "deep fen" "swamp ambience D&D"
python3 tools/music-fetch.py tavern --url "https://youtu.be/..."
python3 tools/music-fetch.py tavern --replace
python3 tools/music-fetch.py tavern --dry-run
"""
import argparse
import random
import re
import string
import sys
from pathlib import Path
# ── Paths ────────────────────────────────────────────────
BASE = Path(__file__).resolve().parent.parent
SESSION = BASE / 'session'
AUDIO_DIR = SESSION / 'audio'
OPTIONS_PATH = SESSION / 'ambience_options.md'
SOURCES_PATH = SESSION / 'ambience_sources.md'
# ── Default search queries per ambience ───────────────────
DEFAULT_QUERIES = {
"tavern": "D&D fantasy tavern inn ambience background music",
"forest": "D&D forest enchanted woodland ambience background music",
"combat": "D&D combat battle epic fantasy music",
"dungeon": "D&D dungeon cave ambience background music",
"tension": "D&D tension suspense dark ambient music",
"calm": "D&D calm peaceful relaxing ambience",
"town": "D&D town village marketplace ambience",
"wilds": "D&D wilderness nature ambient music",
}
# ── Helpers ──────────────────────────────────────────────
def _import_ytdlp():
try:
from yt_dlp import YoutubeDL
return YoutubeDL
except ImportError:
print("Error: yt-dlp not found. Install it with: pip install yt-dlp")
sys.exit(1)
def _check_ffmpeg():
import shutil
if not shutil.which("ffmpeg"):
print("Error: ffmpeg not found. Install it (apt install ffmpeg, brew install ffmpeg, etc.)")
sys.exit(1)
def sanitize_filename(name):
safe = "-_.() " + string.ascii_letters + string.digits
return "".join(c if c in safe else "_" for c in name).strip()
def make_stem(ambience, existing_files):
"""Generate next sequential stem, e.g. tavern_03"""
nums = []
for f in existing_files:
m = re.search(rf"^{re.escape(ambience)}_(\d+)", f.stem)
if m:
nums.append(int(m.group(1)))
idx = max(nums) + 1 if nums else 1
return f"{ambience}_{idx:02d}"
# ── YouTube search / info ─────────────────────────────────
def search_videos(query, max_results=20):
YoutubeDL = _import_ytdlp()
ydl = YoutubeDL({"quiet": True, "no_warnings": True})
info = ydl.extract_info(f"ytsearch{max_results}:{query}", download=False)
return info.get("entries", [])
def get_video_info(url):
YoutubeDL = _import_ytdlp()
ydl = YoutubeDL({"quiet": True, "no_warnings": True})
return ydl.extract_info(url, download=False)
# ── Download ──────────────────────────────────────────────
def download_audio(url, outtmpl):
YoutubeDL = _import_ytdlp()
ydl_opts = {
"quiet": True,
"no_warnings": True,
"outtmpl": outtmpl,
"format": "bestaudio/best",
"postprocessors": [{
"key": "FFmpegExtractAudio",
"preferredcodec": "vorbis",
"preferredquality": "192",
}],
}
with YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
# ── ambience_options.md table helpers ─────────────────────
def _find_table_bounds(lines):
"""Return (start, end) of the first contiguous |...| table block."""
start = end = None
for i, line in enumerate(lines):
s = line.strip()
is_row = s.startswith("|") and s.endswith("|")
if is_row and start is None:
start = i
if is_row:
end = i + 1
elif start is not None:
break
return start, end if end else start
def parse_table():
"""Parse ambience_options.md table into {name: [filename, ...]}"""
if not OPTIONS_PATH.exists():
return {}
lines = OPTIONS_PATH.read_text().splitlines()
start, end = _find_table_bounds(lines)
if start is None:
return {}
entries = {}
for line in lines[start:end]:
s = line.strip()
parts = [p.strip() for p in s.split("|")]
parts = [p for p in parts if p]
if len(parts) < 2:
continue
if all(c in "-:| " for c in s):
continue
if parts[0].lower() == "ambience":
continue
name = parts[0].lower()
raw = parts[1]
if raw == "(stops all music)" or raw == "(no files)":
entries[name] = []
else:
entries[name] = [AUDIO_DIR / f.strip() for f in raw.split(",") if f.strip()]
return entries
def rebuild_table(entries):
"""Render entries as a markdown table."""
table = [
"| Ambience | Files |",
"|----------|-------|",
]
if "silence" in entries or not entries:
table.append("| silence | (stops all music) |")
for name in sorted(entries.keys()):
if name == "silence":
continue
files = entries[name]
if files:
table.append(f"| {name} | {', '.join(f.name for f in files)} |")
else:
table.append(f"| {name} | (no files) |")
return "\n".join(table)
def update_options_file(entries):
"""Replace the table in ambience_options.md while preserving surrounding text."""
if not OPTIONS_PATH.exists():
OPTIONS_PATH.write_text("# Ambience Options\n\n" + rebuild_table(entries) + "\n")
return
lines = OPTIONS_PATH.read_text().splitlines()
start, end = _find_table_bounds(lines)
new_table = rebuild_table(entries)
if start is None:
# No existing table — append after a blank line
body = "\n".join(lines) + "\n\n" + new_table + "\n"
else:
body = "\n".join(lines[:start]) + "\n" + new_table + "\n" + "\n".join(lines[end:])
body = body.strip() + "\n"
OPTIONS_PATH.write_text(body)
# ── ambience_sources.md helpers ──────────────────────────
def _find_sources_table_bounds(lines):
"""Return (start, end) of the sources table block."""
start = end = None
for i, line in enumerate(lines):
s = line.strip()
is_row = s.startswith("|") and s.endswith("|")
if is_row and start is None:
start = i
if is_row:
end = i + 1
elif start is not None:
break
return start, end if end else start
def parse_sources():
"""Parse ambience_sources.md into {filename: (ambience, url)}"""
if not SOURCES_PATH.exists():
return {}
lines = SOURCES_PATH.read_text().splitlines()
start, end = _find_sources_table_bounds(lines)
if start is None:
return {}
sources = {}
for line in lines[start:end]:
s = line.strip()
parts = [p.strip() for p in s.split("|")]
parts = [p for p in parts if p]
if len(parts) < 3:
continue
if all(c in "-:| " for c in s):
continue
if parts[0].lower() == "file":
continue
filename, ambience, url = parts[0], parts[1], parts[2]
sources[filename] = (ambience, url)
return sources
def rebuild_sources_table(sources):
"""Render sources dict as a markdown table."""
table = [
"| File | Ambience | Source |",
"|------|----------|--------|",
]
for filename in sorted(sources.keys()):
ambience, url = sources[filename]
table.append(f"| {filename} | {ambience} | {url} |")
return "\n".join(table)
def update_sources_file(sources):
"""Replace the table in ambience_sources.md while preserving surrounding text."""
if not SOURCES_PATH.exists():
SOURCES_PATH.write_text("# Ambience Sources\n\n" + rebuild_sources_table(sources) + "\n")
return
lines = SOURCES_PATH.read_text().splitlines()
start, end = _find_sources_table_bounds(lines)
new_table = rebuild_sources_table(sources)
if start is None:
body = "\n".join(lines) + "\n\n" + new_table + "\n"
else:
body = "\n".join(lines[:start]) + "\n" + new_table + "\n" + "\n".join(lines[end:])
body = body.strip() + "\n"
SOURCES_PATH.write_text(body)
# ── Main ──────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Fetch ambience music from YouTube and wire into the Chaos TUI.",
)
parser.add_argument("ambience", help="Ambience name (e.g. tavern, forest, combat)")
parser.add_argument("query", nargs="?", default=None,
help="Search query (auto-generated from ambience name if omitted)")
parser.add_argument("--url", default=None,
help="Specific YouTube URL (overrides search)")
parser.add_argument("--replace", action="store_true",
help="Replace existing tracks for this ambience instead of appending")
parser.add_argument("--dry-run", action="store_true",
help="Show what would be fetched without downloading")
parser.add_argument("--max-results", type=int, default=20,
help="Number of search results to consider (default: 20)")
args = parser.parse_args()
name = args.ambience.strip().lower()
if not name:
print("Error: ambience name is required.")
sys.exit(1)
_check_ffmpeg()
# Resolve query
query = args.query
if not query:
if name in DEFAULT_QUERIES:
query = DEFAULT_QUERIES[name]
else:
query = f"D&D {name} ambience background music"
print(f" query: {query}")
# Parse existing table
entries = parse_table()
existing = entries.get(name, [])
if args.replace:
existing = []
# Get candidate video(s)
if args.url:
YoutubeDL = _import_ytdlp()
ydl = YoutubeDL({"quiet": True, "no_warnings": True})
video = ydl.extract_info(args.url, download=False)
candidates = [video]
source_desc = args.url
else:
candidates = search_videos(query, args.max_results)
if not candidates:
print("No results found.")
sys.exit(1)
source_desc = f"top {len(candidates)} results for \"{query}\""
# Pick randomly from the pool
video = random.choice(candidates)
candidates = [video]
video = candidates[0]
title = video.get("title", "unknown")
url = video.get("webpage_url", video.get("url", args.url or "?"))
duration = video.get("duration", 0)
mins, secs = divmod(duration, 60) if duration else (0, 0)
print(f" picked: \"{title}\"")
print(f" url: {url}")
print(f" length: {mins}:{secs:02d}")
if args.dry_run:
stem = make_stem(name, existing)
print(f" target: {AUDIO_DIR / (stem + '.ogg')}")
print(" (dry-run, nothing saved)")
return
# Download
stem = make_stem(name, existing)
outtmpl = str(AUDIO_DIR / stem) # no extension — yt-dlp handles it
print(f" downloading...", end=" ", flush=True)
download_audio(url, outtmpl)
target = AUDIO_DIR / f"{stem}.ogg"
print("done.")
if not target.exists():
print(f"Warning: expected file not found at {target}")
print(f"Check session/audio/ for the actual output.")
sys.exit(1)
# Wire into options config
existing.append(target)
entries[name] = existing
update_options_file(entries)
# Record source
sources = parse_sources()
if args.replace:
# Remove stale entries for this ambience
sources = {k: v for k, v in sources.items() if v[0] != name}
sources[target.name] = (name, url)
update_sources_file(sources)
total = len(existing)
print(f" saved: {target}")
print(f" wired: {name} now has {total} track{'s' if total != 1 else ''}")
print(f" source: recorded in ambience_sources.md")
if __name__ == "__main__":
main()

View File

@ -3,9 +3,11 @@
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
@ -16,6 +18,13 @@ 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
@ -23,6 +32,10 @@ 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'
@ -39,6 +52,22 @@ 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 []
@ -85,6 +114,107 @@ 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."""
@ -97,6 +227,11 @@ class AutoStatic(Static):
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()
@ -113,7 +248,16 @@ class StatusBar(AutoStatic):
def load(self):
char = status_summary()
count = log_count()
self.update(f"{char}{count} entries │ {TODAY}")
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 ──────────────────────────────────────────────
@ -124,7 +268,6 @@ class ChaosTUI(App):
background: #121212;
}
/* Top banner */
#banner {
dock: top;
height: 1;
@ -133,7 +276,6 @@ class ChaosTUI(App):
text-align: center;
}
/* Bottom input */
#input-row {
dock: bottom;
height: 3;
@ -151,16 +293,28 @@ class ChaosTUI(App):
border: none;
}
/* Main area: log (left) + sidebar (right) */
#main {
height: 100%;
}
/* Log column */
#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;
@ -173,7 +327,6 @@ class ChaosTUI(App):
color: #c8c8c8;
}
/* Sidebar */
#sidebar {
width: 36;
min-width: 28;
@ -204,10 +357,20 @@ class ChaosTUI(App):
("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"):
@ -221,6 +384,12 @@ class ChaosTUI(App):
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):
@ -228,12 +397,17 @@ class ChaosTUI(App):
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():
app = ChaosTUI()
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()