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__/ __pycache__/
*.pyc *.pyc
.env .env
session/audio/

View File

@ -9,11 +9,16 @@ the-chaos/
├── rules/ # LOCKED — the game itself, do not modify ├── rules/ # LOCKED — the game itself, do not modify
│ ├── deck/ # Card tables (souls, cook, creatures, curiosities) │ ├── deck/ # Card tables (souls, cook, creatures, curiosities)
│ └── mechanics.md # Core rules reference │ └── mechanics.md # Core rules reference
├── tools/ # LOCKED — CLI helpers (draw.py, roll.py, run.py) ├── tools/ # LOCKED — CLI helpers (draw.py, roll.py, run.py, ambience.py)
└── session/ # UNLOCKED — our campaign └── session/ # UNLOCKED — our campaign
├── character.md # Player character sheet ├── character.md # Player character sheet
├── world.md # Keep & Realm state (NPCs, locations, threads) ├── world.md # Keep & Realm state (NPCs, locations, threads)
├── journal.md # TODO / DONE task tracking
├── tweaks.md # House rules log ├── 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 └── log/ # Raw session logs by date
``` ```
@ -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. 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 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 4. **Player rolls dice physically** — they report results, you narrate outcomes
5. **Log everything** — after each meaningful beat, append to `session/log/<today>.md` 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. **Update files immediately** — damage taken, loot gained, NPCs met → update `character.md` and `world.md` right away 6. **Keep journal.md** — Add tasks to `session/journal.md` under `## TODO`. Move them to `## DONE` when completed.
7. **Keep tweaks.md** — if you make a house rule or add a custom table, log it in `tweaks.md` 7. **Update files immediately** — damage taken, loot gained, NPCs met → update `character.md` and `world.md` right away, before the next narration.
8. **Death is real** — if the PC dies, help the player roll a new character. That's the game. 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 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 | | 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 run.py The Chaos TTRPG Session Client
Layout: banner top | log (main) + character (right) | input bottom. Layout: banner top | log (main) + character (right) | input bottom.
Music: polls session/ambience.md, crossfades via pygame.mixer.
""" """
import os import os
import random
import sys import sys
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
@ -16,6 +18,13 @@ from textual.containers import Horizontal, Vertical
from textual.reactive import reactive from textual.reactive import reactive
from textual.widgets import Input, Static from textual.widgets import Input, Static
# ── Optional pygame ───────────────────────────────────────
try:
import pygame
HAS_PYGAME = True
except ImportError:
HAS_PYGAME = False
# ── Paths ──────────────────────────────────────────────── # ── Paths ────────────────────────────────────────────────
BASE = Path(__file__).resolve().parent.parent BASE = Path(__file__).resolve().parent.parent
@ -23,6 +32,10 @@ SESSION = BASE / 'session'
LOG_DIR = SESSION / 'log' LOG_DIR = SESSION / 'log'
CHAR_PATH = SESSION / 'character.md' CHAR_PATH = SESSION / 'character.md'
WORLD_PATH = SESSION / 'world.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() TODAY = date.today().isoformat()
LOG_PATH = LOG_DIR / f'{TODAY}.md' LOG_PATH = LOG_DIR / f'{TODAY}.md'
@ -39,6 +52,22 @@ def append_log(text):
with open(LOG_PATH, 'a') as f: with open(LOG_PATH, 'a') as f:
f.write(f"- {text}\n") 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): def read_log_tail(n=200):
if not LOG_PATH.exists(): if not LOG_PATH.exists():
return [] return []
@ -85,6 +114,107 @@ def log_count():
return len(read_log_tail()) 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 ─────────────────────────────── # ── Auto-refreshing panels ───────────────────────────────
class AutoStatic(Static): class AutoStatic(Static):
"""A Static that reloads its content on an interval.""" """A Static that reloads its content on an interval."""
@ -97,6 +227,11 @@ class AutoStatic(Static):
self.set_interval(REFRESH_SECS, 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): class TranscriptPane(AutoStatic):
def load(self): def load(self):
lines = read_log_tail() lines = read_log_tail()
@ -113,7 +248,16 @@ class StatusBar(AutoStatic):
def load(self): def load(self):
char = status_summary() char = status_summary()
count = log_count() 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 ────────────────────────────────────────────── # ── The App ──────────────────────────────────────────────
@ -124,7 +268,6 @@ class ChaosTUI(App):
background: #121212; background: #121212;
} }
/* Top banner */
#banner { #banner {
dock: top; dock: top;
height: 1; height: 1;
@ -133,7 +276,6 @@ class ChaosTUI(App):
text-align: center; text-align: center;
} }
/* Bottom input */
#input-row { #input-row {
dock: bottom; dock: bottom;
height: 3; height: 3;
@ -151,16 +293,28 @@ class ChaosTUI(App):
border: none; border: none;
} }
/* Main area: log (left) + sidebar (right) */
#main { #main {
height: 100%; height: 100%;
} }
/* Log column */
#log-col { #log-col {
border-right: solid #3a3a3a; border-right: solid #3a3a3a;
background: #111111; 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 { #log-header {
background: #1d2d1d; background: #1d2d1d;
color: #7dcd7d; color: #7dcd7d;
@ -173,7 +327,6 @@ class ChaosTUI(App):
color: #c8c8c8; color: #c8c8c8;
} }
/* Sidebar */
#sidebar { #sidebar {
width: 36; width: 36;
min-width: 28; min-width: 28;
@ -204,10 +357,20 @@ class ChaosTUI(App):
("escape", "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): def compose(self):
yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner") yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner")
with Horizontal(id="main"): with Horizontal(id="main"):
with Vertical(id="log-col"): with Vertical(id="log-col"):
yield Static("TODO", id="todo-header")
yield TodoPane(id="todo-content")
yield Static("LOG", id="log-header") yield Static("LOG", id="log-header")
yield TranscriptPane(id="transcript") yield TranscriptPane(id="transcript")
with Vertical(id="sidebar"): with Vertical(id="sidebar"):
@ -221,6 +384,12 @@ class ChaosTUI(App):
def on_mount(self): def on_mount(self):
ensure_log() ensure_log()
self.input.focus() 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") @on(Input.Submitted, "#input")
def on_input(self, event: Input.Submitted): def on_input(self, event: Input.Submitted):
@ -228,12 +397,17 @@ class ChaosTUI(App):
if text: if text:
append_log(text) append_log(text)
self.input.clear() self.input.clear()
self.query_one(TodoPane).load()
self.query_one(TranscriptPane).load() self.query_one(TranscriptPane).load()
self.query_one(StatusBar).load() self.query_one(StatusBar).load()
def main(): 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() app.run()