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

View File

@ -63,14 +63,14 @@ Then begin narrating from where things left off.
The core loop for every turn: The core loop for every turn:
1. **Print** `session/turn_description.md` and `session/turn_prompt.md` to the player. 1. **Write** `session/turn_description.md` and `session/turn_prompt.md` with the current scene.
2. **Wait** for the player to fill `session/turn_reaction.md` with their raw reaction. 2. **Ask the player** in the chat to act — they read the scene in the TUI, type their action, and come back here with the result.
3. **Process the turn:** 3. **Process the turn:**
a. Resolve outcomes mechanically — update `character.md`, `world.md`, `journal.md`, and append to `session/log/<today>.md`. a. Resolve outcomes mechanically — update `character.md`, `world.md`, `journal.md`, and append to `session/log/<today>.md`.
b. Rewrite `session/turn_reaction.md` as a coherent narrative continuation of the turn description (reads like a book). b. Rewrite `session/turn_reaction.md` as a coherent narrative continuation of the turn description using full markdown — reads like a book.
c. Run `python3 tools/store_turn.py "<reaction text>"` to append both description and reaction to `session/book.md` and clear all turn temp files. c. Run `python3 tools/store_turn.py "<reaction text>"` to append both description and reaction to `session/book.md` and clear all turn temp files.
4. **Generate a new turn:** 4. **Generate a new turn:**
a. Populate `session/turn_description.md` with the next scene's narrative. a. Populate `session/turn_description.md` with the next scene's narrative using full markdown — **bold** for emphasis, *italic* for thoughts or sounds, `---` for scene breaks, lists for options or details, and quotes for dialogue.
b. Populate `session/turn_prompt.md` with "What do you do?". b. Populate `session/turn_prompt.md` with "What do you do?".
c. Update `session/ambience.md` if the mood has changed. c. Update `session/ambience.md` if the mood has changed.
@ -91,4 +91,4 @@ The core loop for every turn:
## 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 TUI also displays the current ambience in the status bar when music is active. The player may have `tools/run.py` open in another terminal. It displays a live dashboard: TODO (top), CHARACTER/LOG tabs (middle), BOOK viewer with ◀ ▶ page navigation (main), and a status bar. The BOOK pane shows the story book split by turns — use arrow keys or click ◀ ▶ to flip pages. Keep the session files accurate and the TUI reflects game state.

View File

@ -1 +1 @@
dungeon dungeon

View File

@ -1 +1,160 @@
## Turn — 2026-06-25
The common room of the Splintered Tankard hums with the low murmur of morning. Smoke coils from the hearth fire, mixing with the smell of barley stew and wet wool. Mistress Otta Venn moves between tables with the practised ease of a woman who has survived things — her curved knife catching the firelight as she passes.
Dillion sits near the stump of the enormous black tree that gives the tavern its name, finishing a bowl of thick porridge. He is a newcomer to the Keep — a mud-spattered walled town where two rivers meet beneath a granite outcrop. The faces around him are half hopeful, half haunted.
The morning bells ring — tinny and dissonant — and the Market Square beyond the tavern door begins to stir. A dead oak at the square's centre bears a notice board nailed with handbills and pleas.
Dillion drains his cup, leaves a few coppers on the table, and steps out into the Market Square. The board is crowded with papers curling at the edges.
Four notices catch his eye:
**Old mill at Three Bridges.** The miller vanished months ago. Neighbours report a clicking sound at night. Ask at Weber's Smithy. 5 silver offered.
**Vanished caravan.** A merchant caravan on the Eastern road. Cargo found intact. People gone. No tracks. 15 silver at the Guild Hall for information.
**Bones wanted.** Baron's hunters found a pit of bones in the Amber Hills. 2 silver per skeleton delivered to the Guild Hall.
**Lost signet ring.** Family heirloom. Ask at Fenna's Curios. Reward negotiable.
The mill job tugs at him — coin, and close enough to test his mettle. He heads for Weber's Smithy.
## Turn — 2026-06-25
Weber's Smithy clangs and hisses at the west edge of the Market Square. The smith himself is a hunchbacked man with forearms like hawsers, who carries on a constant low conversation with his hammer — a tool he calls "Bell."
"The old mill?" Weber grunts, not looking up from a glowing horseshoe. "Been dead ten years. Then three months ago, the clicking started. Gears turning on their own in the dead of night. Kids dare each other to go look. None of 'em go twice."
He offers 5 silver to investigate. Dillion haggles.
Dillion talks Weber up to 8 silver, plus a free mace sharpening if there's danger. They spit-shake on it.
In the Market Square, Dillion spots Lark — a scrawny errand boy with quick eyes and dirt-caked knees. The kid knows every shortcut in the Keep. For the promise of a share of loot, Lark spills what he knows: a loose board behind the mill's water wheel, scratching sounds from inside, and once — a dark shape moving past a cracked window.
Dillion grabs a pitchfork from a stall for Lark, and the two head east on the King's Road as the sun climbs toward noon.
## Turn — 2026-06-25
The old mill squats at Three Bridges where the Greywater bends east. Its wheel is half-rotted, motionless. The roof sags. Wind moans through gaps in the planking. The air smells of damp wood and something underneath — a sweet, acrid tang.
Through a grime-caked window, Dillion glimpses a dark shape. Motionless. Waiting.
Behind the water wheel, just as Lark said: a loose board, nails rusted half-through. Beyond it, darkness.
Dillion pries the board open — the nails give with a groan. He lights a torch, the flame pushing back a small circle of darkness, and crawls inside.
The storage room is thick with dust. Crates rot in the corners. And above — a rustle of dry sinew.
A creature drops from the ceiling. Pale skin, sinewy limbs, a mouth full of too many needles. It hisses and lunges.
Dillion's mace meets it mid-air. The impact crunches ribs — 2 damage, and the creature staggers. Before it can recover, Dillion brings the mace down on its skull. A wet crack, and it crumples. 5 damage — dead.
He cuts off an ear as proof for Weber, and finds the creature's gut holds 3 silver and a rusted iron key.
## Turn — 2026-06-25
The iron key fits an iron-bound chest in the corner. Inside: 12 silver, a silver locket holding a lock of auburn hair, and a bundle of love letters — from a woman named Marren to a woman named Elara. The later letters grow desperate. Someone was watching the mill. Someone in hoods.
Upstairs, the creature's nest: shredded cloth, gnawed bones, a scattering of coins. And a leather-bound journal. The miller's hand: cramped, sloping.
He'd trapped the creature in the cellar. Then the Guild came — stored something below. Then the hooded watchers arrived. The last entry is a single line:
*"They know I've seen it. I'll seal the hatch and pray."*
A clay jar sits beside the journal. Grey powder inside, and a note: *"For the lock. Sprinkle on the hasp."* A collapsed shelf hides a padlocked trapdoor in the floorboards.
Dillion sprinkles the grey powder on the padlock. The metal hisses, bubbles, and crumbles like chalk. He swings the trapdoor open and shouts into the darkness below — a taunt, a challenge.
Something answers. A smaller version of the pale creature scrabbles up the ladder. Dillion's mace takes its head off in a single blow — 7 damage. It twitches and stills.
He lights a fresh torch and drops it into the cellar. The light reveals an iron chest — and a woman, gaunt and filthy, shielding her eyes. Lark pats her down — clean. She says her name is Rina. A locksmith. Hired to crack the chest. Ambushed by hooded figures. Locked in for three days.
Dillion jumps down. The chest is empty — whatever was inside is gone. But on the floor lies a brass button bearing a crest: a closed fist gripping a gear. Beyond a crack in the wall, a natural tunnel slopes downward, its walls sweating moisture. The air moving through it smells of the river — it likely passes under the Greywater.
Dillion decides to head back to the Keep. Report to Weber. Get paid. Gear up Rina. The tunnel can wait.
## Turn — 2026-06-25
Back at the Keep, morning light washes the muddy streets. Dillion leads Rina and Lark through the Market Square, drawing a few glances — a blood-spattered man, a gaunt woman in a dead miller's coat, a grinning boy carrying a pitchfork.
At Weber's Smithy, the hunchback examines the creature's ear with a grunt of approval. He counts out 8 silver and sharpens Dillion's mace — the edge gleams, fresh and deadly. He eyes Rina. "She one of Fenna's lot?"
At the Splintered Tankard, Otta takes one look at Rina and ushers her to the back for a bath and clean clothes. Dillion has a pocket of time.
Dillion hits the Market Square. He buys 6 fresh torches and 5 days of rations. He pays Lark 5 silver for his help — the boy pockets it with a wide grin and disappears into the crowd.
Rina emerges from the Tankard's back room scrubbed pink, wearing a patched shirt and breeches that Otta found for her. Over a bowl of stew, she lays out her skills: locks, traps, a steady hand with a crossbow, and the quiet nerves of someone who has worked in the dark before.
Dillion takes her to Weber's for a light crossbow, then to a stall for a padded leather vest. Armed and armoured, Rina nods — ready.
They buy two long ropes and head east on the King's Road as the sun begins its afternoon slope. The mill waits. The tunnel waits. And somewhere below, whatever the Guild was hiding.
## Turn — 2026-06-25
The mill is quiet as they slip through the loose board and descend into the cellar. The bodies of the creatures still lie where they fell. The crack in the wall breathes cool, damp air.
Dillion goes first, torch high. The tunnel slopes steadily down, rough-hewn at first, then opening into a natural cavern. The ceiling pulses with bioluminescent fungi — pale blue light that throws no shadows. The air thickens with the smell of damp stone, blood, and something sharp — mint? Medicine?
A stone door stands on the far wall, carved with the fist-and-gear crest. It sits slightly ajar.
Beyond it: a chamber. A tall construct of grey stone stands motionless — red eyes fixed on the entrance. It wears tattered cloth and rusted armour. It watches. It does not attack.
Rina spots a tripwire at ankle height. They step over it and edge along the wall toward a left-hand corridor. The construct's red eyes track them, but it does not move.
The corridor leads to an iron-banded wooden door with a heavy lock. Rina's picks are quiet. The lock clicks open.
Beyond: a storage room. Two men — one bearded, one scarred — sit playing cards on an upturned crate. Crossbows lean against the wall.
Dillion and Rina slip behind a stack of crates. The guards haven't seen them.
Dillion catches Rina's eye. She nods — she's ready. On his signal, she rises and looses a bolt. It takes Scar through the neck — he gurgles and drops, dead before he hits the floor.
Beard lunges for his crossbow. Dillion is faster — his mace catches the man across the throat. A wet crunch. Beard joins his partner.
Silent. Clean.
They drag the bodies into the shadows. Search yields: 19 silver, a brass key ring with two keys, and a handbill bearing the fist-and-gear crest — a list of names with "Kellis — LATE" scrawled beside one.
Rina finds a stack of delivery manifests and a folded note: *"The Weeper is restless. Bring another before the Chime. — H."*
An iron-reinforced door stands at the far end. Dillion listens — nothing. He rigs a rope from behind the crates and opens it from a safe distance. Beyond: darkness, and a tunnel sloping deeper into the earth.
The air grows warmer, wetter. A sound emerges from the dark ahead — slow, rhythmic breathing. Not human.
The tunnel opens into a chamber with a pit at its centre. Chains descend into the darkness. The breathing comes from below. On the far side, a second iron door.
Dillion and Rina skirt the pit, keeping to the wall. Whatever is down there does not stir.
Beyond the far door: a short hallway ends in a heavy velvet curtain. Warm light glows at its edges. A voice chants in a low rhythm — words Dillion doesn't recognise.
Dillion parts the curtain a finger's width. A study. A chalk circle on the floor, candles at each cardinal point. A dagger at the centre, its blade dark with old blood. A figure in a hooded robe stands over it, chanting.
A weaver. Working something.
Dillion lets the curtain fall and steps back to consult Rina.
## Turn — 2026-06-25
You pull back from the velvet curtain, your heart hammering. Rina watches you, crossbow raised, eyes asking the question.
You motion her closer, pressing your back to the damp stone wall. The chanting continues beyond the curtain — a low, rhythmic murmur in a language you don't recognise. Male voice. Steady. Building toward something.
*The room beyond:* Warm candlelight. A chalk circle on the floor — intricate, layered symbols spiralling inward to a centre where a curved dagger rests on what looks like a folded piece of cloth. Shelves along the walls crammed with jars, scrolls, bones. A low wooden table with an open book, a smoking censer, and a human skull.
And the weaver himself — you caught only a glimpse. Dark robes. Seated cross-legged before the circle, hands raised, chanting.
The corridor continues past the curtain — you saw it curve to the right before you pulled back. Another door, maybe, or another chamber.
Rina whispers: *"That's blood magick. I've seen the signs before. He's not just praying — he's winding something up. If he finishes..."*
She looks at you, waiting.
Waits and deliberates, weighing the risk of waiting for the ritual to finish against the danger of interrupting a blood-weaver mid-chant.

View File

@ -13,3 +13,4 @@
- **Late Afternoon** — Beyond the door: a storage room with two men (Beard and Scar) on guard duty. Dillion and Rina slip into cover behind crates (DEX 9). Ambush: Rina bolts Scar through the neck, Dillion cuts Beard's throat. Both killed silently. - **Late Afternoon** — Beyond the door: a storage room with two men (Beard and Scar) on guard duty. Dillion and Rina slip into cover behind crates (DEX 9). Ambush: Rina bolts Scar through the neck, Dillion cuts Beard's throat. Both killed silently.
- **Late Afternoon** — Bodies hidden. Dillion searches the guards (19 silver, key ring, handbill). Rina finds delivery manifests and a note mentioning "The Weeper" and "H." Dillion takes the loot and moves to the iron-reinforced door. Listens. Opens it safely with a rope from behind crates. Beyond: a dark tunnel sloping downward, humid air, slow rhythmic breathing sound. - **Late Afternoon** — Bodies hidden. Dillion searches the guards (19 silver, key ring, handbill). Rina finds delivery manifests and a note mentioning "The Weeper" and "H." Dillion takes the loot and moves to the iron-reinforced door. Listens. Opens it safely with a rope from behind crates. Beyond: a dark tunnel sloping downward, humid air, slow rhythmic breathing sound.
- **Late Afternoon** — Dillion and Rina descend into the tunnel. It opens into a chamber with a pit at the centre — chains descending into darkness, slow wet breathing from below. A second iron door on the far side. Dillion and Rina skirt the pit silently (DEX 7). Beyond the door: a hallway with a velvet curtain, warm light and a chanting voice beyond. Dillion peeks: a weaver's study, chalk circle with a dagger at the centre. He pulls back to consult Rina. - **Late Afternoon** — Dillion and Rina descend into the tunnel. It opens into a chamber with a pit at the centre — chains descending into darkness, slow wet breathing from below. A second iron door on the far side. Dillion and Rina skirt the pit silently (DEX 7). Beyond the door: a hallway with a velvet curtain, warm light and a chanting voice beyond. Dillion peeks: a weaver's study, chalk circle with a dagger at the centre. He pulls back to consult Rina.
- **Late Afternoon** — Dillion weighs the options — wait for the ritual to finish and snatch the artifact, or strike now. Rina warns it's blood magick. The weaver's chant climbs toward its peak. Decision hangs in the air.

View File

@ -0,0 +1,9 @@
The weaver's voice rises to a shout — a single word, sharp and resonant. The candle flames all gutter at once, then flare back a deep, pulsing red. A low thrum vibrates through the stone under your boots.
Through the gap in the curtain, you see the dagger in the circle begin to glow — a dull, hungry orange, like iron pulled from a forge. The weaver's hands tremble as he holds them over the blade, chanting faster now, sweat gleaming on his face.
Rina's fingers brush your arm. She points — past the curtain, where the corridor curves right. A heavy wooden door, banded in iron. The same crest: a fist gripping a gear.
The weaver's chant is building toward its final note. The dagger pulses brighter. Whatever he's summoning is almost here.
You have seconds.

1
session/turn_prompt.md Normal file
View File

@ -0,0 +1 @@
What do you do?

1
session/turn_reaction.md Normal file
View File

@ -0,0 +1 @@
While he is busy, we should find a good hiding spot in the room, wait and see what happens.

View File

@ -2,7 +2,7 @@
""" """
run.py The Chaos TTRPG Session Client 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. Music: polls session/ambience.md, plays via miniaudio.
""" """
@ -15,7 +15,7 @@ from pathlib import Path
from textual import on from textual import on
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll 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 from rich.markdown import Markdown as RichMarkdown
# ── Optional miniaudio ──────────────────────────────────── # ── Optional miniaudio ────────────────────────────────────
@ -36,9 +36,7 @@ WORLD_PATH = SESSION / 'world.md'
JOURNAL_PATH = SESSION / 'journal.md' JOURNAL_PATH = SESSION / 'journal.md'
AMBIENCE_PATH = SESSION / 'ambience.md' AMBIENCE_PATH = SESSION / 'ambience.md'
AMBIENCE_OPTIONS_PATH = SESSION / 'ambience_options.md' AMBIENCE_OPTIONS_PATH = SESSION / 'ambience_options.md'
TURN_DESC_PATH = SESSION / 'turn_description.md' BOOK_PATH = SESSION / 'book.md'
TURN_PROMPT_PATH = SESSION / 'turn_prompt.md'
TURN_REACTION_PATH = SESSION / 'turn_reaction.md'
AUDIO_DIR = SESSION / 'audio' 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'
@ -93,19 +91,6 @@ def read_log_tail(n=200):
lines = LOG_PATH.read_text().splitlines() lines = LOG_PATH.read_text().splitlines()
return [l for l in lines if l.strip() and not l.startswith('#')][-n:] 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 ─────────────────────────────────────── # ── Status summary ───────────────────────────────────────
def status_summary(): def status_summary():
@ -132,6 +117,18 @@ def log_count():
return len(read_log_tail()) 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 ─────────────────────────────────── # ── Ambience subsystem ───────────────────────────────────
def parse_ambience_options(): def parse_ambience_options():
"""Parse ambience_options.md into {name: [filepath, ...]}""" """Parse ambience_options.md into {name: [filepath, ...]}"""
@ -282,19 +279,6 @@ class StatusBar(AutoStatic):
self.update(f"{char}{count} entries │ {todo} todo │ {TODAY}{music}") 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 # module-level ref so StatusBar can reach it
app_ambience_player = None app_ambience_player = None
@ -315,23 +299,6 @@ class ChaosTUI(App):
text-align: center; 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 { #main {
height: 100%; height: 100%;
background: #111111; background: #111111;
@ -351,6 +318,7 @@ class ChaosTUI(App):
height: 5; height: 5;
max-height: 5; max-height: 5;
overflow-y: auto; overflow-y: auto;
scrollbar-size-vertical: 2;
} }
#middle-tabs { #middle-tabs {
@ -378,17 +346,60 @@ class ChaosTUI(App):
padding: 0 1; padding: 0 1;
} }
#turn-header { #book-section {
height: 1fr;
layout: vertical;
}
#book-header {
background: #2d2d2d; background: #2d2d2d;
color: #e0c080; color: #e0c080;
text-style: bold; text-style: bold;
padding: 0 1; padding: 0 1;
height: 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; height: 1fr;
} }
#turn-content { #book-content {
background: #161616; background: #161616;
color: #d8d8d8; color: #d8d8d8;
padding: 0 2; padding: 0 2;
@ -406,6 +417,14 @@ class ChaosTUI(App):
BINDINGS = [ BINDINGS = [
("ctrl+c", "quit", "Quit"), ("ctrl+c", "quit", "Quit"),
("escape", "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): def __init__(self, *args, no_music=False, **kwargs):
@ -415,6 +434,8 @@ class ChaosTUI(App):
app_ambience_player = AmbiencePlayer() app_ambience_player = AmbiencePlayer()
else: else:
app_ambience_player = None app_ambience_player = None
self._book_page = 0
self._book_pages = []
def compose(self): def compose(self):
yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner") yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner")
@ -428,36 +449,87 @@ class ChaosTUI(App):
with TabPane("LOG", id="log-tab"): with TabPane("LOG", id="log-tab"):
with VerticalScroll(): with VerticalScroll():
yield TranscriptPane(id="transcript") yield TranscriptPane(id="transcript")
yield Static("TURN", id="turn-header") yield Static("BOOK", id="book-header")
with VerticalScroll(id="turn-scroll"): with Vertical(id="book-section"):
yield TurnPane(id="turn-content") 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") 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): def on_mount(self):
ensure_log() ensure_log()
if not TURN_REACTION_PATH.exists(): self._reload_book()
TURN_REACTION_PATH.write_text('') self._book_page = len(self._book_pages) - 1
self.input.focus() self._render_book_page()
self.set_interval(REFRESH_SECS, self._check_ambience) self.set_interval(REFRESH_SECS, self._check_ambience)
self.set_interval(REFRESH_SECS, self._reload_book)
def _check_ambience(self): def _check_ambience(self):
if app_ambience_player: if app_ambience_player:
app_ambience_player.poll() app_ambience_player.poll()
@on(Input.Submitted, "#input") def _reload_book(self):
def on_input(self, event: Input.Submitted): self._book_pages = load_book_pages()
text = event.value.strip() self._book_page = max(0, min(self._book_page, len(self._book_pages) - 1))
if text: self._render_book_page()
write_reaction(text)
self.input.clear() def _render_book_page(self):
self.query_one(TodoPane).load() self.query_one("#book-content").update(RichMarkdown(self._book_pages[self._book_page]))
self.query_one(CharPane).load() total = len(self._book_pages)
self.query_one(TranscriptPane).load() self.query_one("#book-page-label").update(f"Page {self._book_page + 1} of {total}")
self.query_one(TurnPane).load() self.query_one("#book-hint").update("h prev | l next | j +5 | k -5 | g first | G last")
self.query_one(StatusBar).load() 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(): def main():