book viewer: three-line nav center with page label, hints, progress bar

This commit is contained in:
Dejvino
2026-06-25 08:20:35 +02:00
parent 2cfd32ca55
commit 8ee5bc4979
8 changed files with 320 additions and 77 deletions
+143 -71
View File
@@ -2,7 +2,7 @@
"""
run.py — The Chaos TTRPG Session Client
Layout: banner | TODO | CHAR/LOG (tabs) | TURN (desc + prompt) | status | input.
Layout: banner | TODO | CHAR/LOG (tabs) | BOOK (paged) | status.
Music: polls session/ambience.md, plays via miniaudio.
"""
@@ -15,7 +15,7 @@ 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 Input, Static, TabbedContent, TabPane
from textual.widgets import Button, Static, TabbedContent, TabPane
from rich.markdown import Markdown as RichMarkdown
# ── Optional miniaudio ────────────────────────────────────
@@ -36,9 +36,7 @@ WORLD_PATH = SESSION / 'world.md'
JOURNAL_PATH = SESSION / 'journal.md'
AMBIENCE_PATH = SESSION / 'ambience.md'
AMBIENCE_OPTIONS_PATH = SESSION / 'ambience_options.md'
TURN_DESC_PATH = SESSION / 'turn_description.md'
TURN_PROMPT_PATH = SESSION / 'turn_prompt.md'
TURN_REACTION_PATH = SESSION / 'turn_reaction.md'
BOOK_PATH = SESSION / 'book.md'
AUDIO_DIR = SESSION / 'audio'
TODAY = date.today().isoformat()
LOG_PATH = LOG_DIR / f'{TODAY}.md'
@@ -93,19 +91,6 @@ def read_log_tail(n=200):
lines = LOG_PATH.read_text().splitlines()
return [l for l in lines if l.strip() and not l.startswith('#')][-n:]
def read_turn_description():
if not TURN_DESC_PATH.exists():
return ""
return TURN_DESC_PATH.read_text().strip()
def read_turn_prompt():
if not TURN_PROMPT_PATH.exists():
return ""
return TURN_PROMPT_PATH.read_text().strip()
def write_reaction(text):
TURN_REACTION_PATH.write_text(text + '\n')
# ── Status summary ───────────────────────────────────────
def status_summary():
@@ -132,6 +117,18 @@ 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, ...]}"""
@@ -282,19 +279,6 @@ class StatusBar(AutoStatic):
self.update(f"{char} │ {count} entries │ {todo} todo │ {TODAY}{music}")
class TurnPane(AutoStatic):
def load(self):
desc = read_turn_description()
prompt = read_turn_prompt()
parts = []
if desc:
parts.append(desc)
if prompt:
parts.append(f"---\n\n*{prompt}*")
content = "\n\n".join(parts) if parts else "*The world waits.*"
self.update(RichMarkdown(content))
# module-level ref so StatusBar can reach it
app_ambience_player = None
@@ -315,23 +299,6 @@ class ChaosTUI(App):
text-align: center;
}
#input-row {
dock: bottom;
height: 3;
background: #252525;
padding: 0 0;
border-top: solid #3a3a3a;
}
Input {
background: #1e1e1e;
color: #e0e0e0;
border: none;
margin: 1 1;
}
Input:focus {
border: none;
}
#main {
height: 100%;
background: #111111;
@@ -351,6 +318,7 @@ class ChaosTUI(App):
height: 5;
max-height: 5;
overflow-y: auto;
scrollbar-size-vertical: 2;
}
#middle-tabs {
@@ -378,17 +346,60 @@ class ChaosTUI(App):
padding: 0 1;
}
#turn-header {
#book-section {
height: 1fr;
layout: vertical;
}
#book-header {
background: #2d2d2d;
color: #e0c080;
text-style: bold;
padding: 0 1;
height: 1;
}
#turn-scroll {
#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;
}
#turn-content {
#book-content {
background: #161616;
color: #d8d8d8;
padding: 0 2;
@@ -406,6 +417,14 @@ class ChaosTUI(App):
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):
@@ -415,6 +434,8 @@ class ChaosTUI(App):
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")
@@ -428,36 +449,87 @@ class ChaosTUI(App):
with TabPane("LOG", id="log-tab"):
with VerticalScroll():
yield TranscriptPane(id="transcript")
yield Static("TURN", id="turn-header")
with VerticalScroll(id="turn-scroll"):
yield TurnPane(id="turn-content")
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")
with Horizontal(id="input-row"):
self.input = Input(placeholder=" What do you do? (just type)", id="input")
yield self.input
def on_mount(self):
ensure_log()
if not TURN_REACTION_PATH.exists():
TURN_REACTION_PATH.write_text('')
self.input.focus()
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()
@on(Input.Submitted, "#input")
def on_input(self, event: Input.Submitted):
text = event.value.strip()
if text:
write_reaction(text)
self.input.clear()
self.query_one(TodoPane).load()
self.query_one(CharPane).load()
self.query_one(TranscriptPane).load()
self.query_one(TurnPane).load()
self.query_one(StatusBar).load()
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():