book viewer: three-line nav center with page label, hints, progress bar
This commit is contained in:
+143
-71
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user