diff --git a/.gitignore b/.gitignore index b6cf5f0..b054406 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__/ *.pyc .env +session/audio/ diff --git a/AGENTS.md b/AGENTS.md index ce1af06..8f479ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` 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/.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/.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. diff --git a/session/ambience.md b/session/ambience.md new file mode 100644 index 0000000..b8f766c --- /dev/null +++ b/session/ambience.md @@ -0,0 +1 @@ +silence diff --git a/session/ambience_options.md b/session/ambience_options.md new file mode 100644 index 0000000..50572ee --- /dev/null +++ b/session/ambience_options.md @@ -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. diff --git a/session/ambience_sources.md b/session/ambience_sources.md new file mode 100644 index 0000000..681358f --- /dev/null +++ b/session/ambience_sources.md @@ -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 | +|------|----------|--------| diff --git a/session/tweaks.md b/session/tweaks.md index 90588a6..33e3436 100644 --- a/session/tweaks.md +++ b/session/tweaks.md @@ -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. | diff --git a/tools/ambience.py b/tools/ambience.py new file mode 100755 index 0000000..ceca1bc --- /dev/null +++ b/tools/ambience.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Companion CLI to set ambience for the TUI. + +Usage: + python3 tools/ambience.py + 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 ") + 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() diff --git a/tools/music-fetch.py b/tools/music-fetch.py new file mode 100755 index 0000000..a884280 --- /dev/null +++ b/tools/music-fetch.py @@ -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() diff --git a/tools/run.py b/tools/run.py index 2eabb9e..5655b6f 100755 --- a/tools/run.py +++ b/tools/run.py @@ -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()