Something went wrong. Try again.
A 5e storytelling engine with an LLM DM
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382//! The transcript: everything said so far, in the terminal's own//! scrollback above the viewport.//!//! The DM's narration is markdown, and it reaches the transcript a block//! at a time as it streams. [`MarkdownStream`] holds back the block still//! being written and hands out the rows of every block that is final. The//! terminal does all the scrolling, and the viewport holds no more than//! the block still forming.//!//! The player's lines, storied's own asides, and a tool's line are not//! markdown. Each is one block of plain text that wraps through//! [`crate::wrap`] and goes out whole.//!//! Rows wait here until [`Transcript::place`] puts them out, which the//! play loop calls once a pass, after the viewport has taken the rows it//! needs. A viewport that shrinks gives its rows back below itself, and//! the rows going out above it are what push it down to the bottom of the//! screen again, so the viewport has to know how many rows are waiting//! before it gives any back.
use ratatui::Terminal;use ratatui::backend::Backend;use ratatui::buffer::Buffer;use ratatui::style::{Color, Modifier, Style};use ratatui::text::{Line, Span, Text};
use crate::markdown::MarkdownStream;use crate::wrap::{wrap, wrap_spans};
/// Storied's own asides: the empty-reply line, the interrupted line, and/// what `/context` and `/settings` show. The rule above the prompt is/// drawn in this style too, so everything that is not the game itself/// reads the same way.pub fn aside() -> Style { Style::new().add_modifier(Modifier::DIM)}
/// What the player said.pub fn player() -> Style { Style::new().fg(Color::Cyan)}
/// What went wrong.pub fn failure() -> Style { Style::new().fg(Color::Red)}
/// A dispatched tool call's line: dim (and italic) by default, so the/// DM's bookkeeping reads as quieter than the narration around it. A tool/// that styles one of its own spans — like a public roll's total, made/// bold and full white — overrides the default on that span.pub fn tool() -> Style { Style::new() .add_modifier(Modifier::DIM) .add_modifier(Modifier::ITALIC)}
/// What a transcript row holds. One blank row goes between two rows of/// different kinds, and none between two rows of the same kind, which is/// what packs a round's tool lines into a single block.#[derive(Clone, Copy, Default, PartialEq, Eq)]pub enum Kind { /// What the player typed. Player, /// One row of the DM's narration. Narration, /// One dispatched tool call's line. Tool, /// Storied's own lines: the empty-reply line, the interrupted line, /// and anything that failed. #[default] Aside,}
/// One row on its way out, and whether a blank row goes ahead of it.struct Queued { blank: u16, row: Line<'static>,}
/// The rows already in the terminal's scrollback, the rows waiting to go/// there, the block of the reply still forming, and what the next row/// needs to space itself correctly.#[derive(Default)]pub struct Transcript { /// The rows this pass has gathered, in the order they go out. queued: Vec<Queued>, /// The narration of the round in progress. Every pass takes the rows /// of the blocks it has finished and leaves the block still being /// written. narration: MarkdownStream, /// The width the round in progress renders at, taken from the /// terminal the first time the round has something to render. `None` /// between rounds. /// /// A `MarkdownStream` counts the rows it has handed out, and that /// count only points at the same text while the render behind it /// wraps the same way. A terminal that changes width mid-round would /// re-wrap the whole round to a different number of rows, and the /// count would then point somewhere else: a wider terminal renders /// fewer rows than have printed, so the text after them never goes /// out at all, and a narrower one renders more, so text that has /// already scrolled past prints a second time. Holding the width for /// the length of the round keeps every row of it wrapped the way the /// rows above it were. width: Option<u16>, /// True when one blank row waits to go out ahead of the next row that /// has something on it. A blank row that nothing follows never goes /// out at all, which is what keeps the transcript from ending on /// blank rows. pending_blank: bool, /// What the last row of the transcript holds. kind: Kind, /// True once the transcript has put out its first row. The first row /// never gets a blank row ahead of it, whatever kind it is. started: bool, /// True once this turn has put a narration row or a tool line in the /// transcript. A turn that ends with this still false is the one that /// says the DM said nothing. said: bool,}
impl Transcript { /// Adds what the DM just said to the round in progress. pub fn delta(&mut self, text: &str) { self.narration.push(text); }
/// The rows of the block still being written, which the viewport's /// tail area shows. /// /// A round under way renders at the width it started on, whatever /// `width` says, so these rows line up with the rows already in the /// scrollback. A terminal that narrowed mid-round cuts them off at /// its own edge until the round ends. `width` is what a round that /// has not started yet would render at, and it renders nothing. pub fn forming_rows(&self, width: u16) -> Vec<Line<'static>> { self.narration.forming(self.width.unwrap_or(width)) }
/// True once this turn has put a narration row or a tool line in the /// transcript. pub fn said(&self) -> bool { self.said }
/// How many rows wait to go out, the blank rows between blocks /// counted. The viewport gives back no more rows than these can fill. pub fn waiting(&self) -> u16 { self.queued.iter().map(|queued| queued.blank + 1).sum() }
/// Puts every waiting row above the viewport, in order, into the /// terminal's own scrollback. /// /// This is the only call that writes to the screen outside a draw, so /// the play loop can hold it until the viewport has settled. A /// viewport that just gave rows back ends short of the last row of the /// screen, and these rows land in the rows it gave back and push it /// down to the bottom again. pub fn place<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<(), B::Error> { let width = terminal.size()?.width; for Queued { blank, row } in std::mem::take(&mut self.queued) { let draw = |buffer: &mut Buffer| { buffer.set_line(0, blank, &row, width); }; terminal.insert_before(blank + 1, draw)?; } Ok(()) }
/// Starts the next turn, which has said nothing yet. pub fn next_turn(&mut self) { self.said = false; }
/// Moves the rows of every block the narration has finished into the /// transcript, and keeps the block still being written for the tail /// area, which holds `tail_rows` rows. /// /// A block taller than the tail area spills its top rows into the /// transcript, so the end of it, where the text is still arriving, /// stays in sight. A spilled row freezes as it is. Greedy wrap makes /// that safe for a paragraph, which only appends. An ordered list /// that reaches ten items widens every marker, so a row of one that /// spilled early keeps a marker one column too narrow. /// /// A round with nothing in it yet returns without taking a width. /// The play loop calls this on every pass, several times a second, /// and a round that took its width from the first of those passes /// would render at whatever the terminal was between turns rather /// than at what it is when the DM starts to speak. pub fn stream<B: Backend>( &mut self, terminal: &Terminal<B>, tail_rows: u16, ) -> Result<(), B::Error> { if self.narration.is_empty() { return Ok(()); } let width = self.round_width(terminal)?; for row in self.narration.committable(width) { self.narrate(terminal, row)?; } let forming = self.narration.forming(width).len(); for row in self .narration .spill(width, forming.saturating_sub(tail_rows as usize)) { self.narrate(terminal, row)?; } Ok(()) }
/// Moves the whole narration of the round into the transcript, the /// block still forming included, and empties the tail area. The turn /// is not over: a round ends here, not the turn. /// /// A round that narrated nothing leaves the transcript alone: text /// with no block in it, whitespace or nothing at all, renders to no /// rows. /// /// The width goes back with the stream, so the next round takes the /// terminal's width as it stands then. pub fn flush<B: Backend>(&mut self, terminal: &Terminal<B>) -> Result<(), B::Error> { let width = self.round_width(terminal)?; for row in self.narration.finish(width) { self.narrate(terminal, row)?; } self.width = None; Ok(()) }
/// Puts one row of the DM's narration in the transcript, wrapped /// again when the terminal changed width under the round. /// /// A round renders at the width it started on, so a terminal that /// narrowed mid-round hands rows wider than the screen. /// [`Buffer::set_line`] would drop everything past the last column, /// and a row in the scrollback never repaints, so those words would /// be gone for good. Wrapping the row again at the width the terminal /// has now keeps all of them. It costs the row its own spacing, since /// a wrap collapses runs of whitespace, but a resize has already cost /// the round its layout. /// /// A row too wide at the width the round is rendering at is a /// different thing, and it goes out whole for the terminal to clip. A /// table with more columns than the screen can hold renders past the /// edge on purpose, and every row of it renders past the same edge, /// so clipping cuts them all at one column and what is left still /// lines up. Wrapping those rows instead would break each one at a /// different word and leave nothing to read. A resize mid-round /// still catches one of these rows in the general rewrap above, /// though, and garbles its columns; that case is accepted, since a /// resize has already cost the round its layout. /// /// The extra rows this makes are invisible to /// [`crate::markdown::MarkdownStream`], which counts the rows it /// rendered at the round's own width, not the rows that reach the /// screen. /// /// [`Buffer::set_line`]: ratatui::buffer::Buffer::set_line fn narrate<B: Backend>( &mut self, terminal: &Terminal<B>, row: Line<'static>, ) -> Result<(), B::Error> { let width = terminal.size()?.width; if self.width == Some(width) || row.width() <= width as usize { self.emit(row, Kind::Narration); return Ok(()); } for row in wrap_spans(Text::from(row), width as usize) { self.emit(row, Kind::Narration); } Ok(()) }
/// The width this round renders at: the terminal's own width the /// first time the round asks for it, and that same width every time /// after, until [`Transcript::flush`] ends the round. Only a round /// with something to render asks. fn round_width<B: Backend>(&mut self, terminal: &Terminal<B>) -> Result<u16, B::Error> { match self.width { Some(width) => Ok(width), None => { let width = terminal.size()?.width; self.width = Some(width); Ok(width) } } }
/// Wraps `text` to the terminal width and puts it in the transcript /// as one block: the rows of a wrapped line pack together, and the /// block ends closed, so the next row starts after a blank row even /// when it is of the same kind. A player line and an aside are each /// one whole block, which is why they go in this way and narration /// and tool lines do not. pub fn insert<B: Backend>( &mut self, terminal: &Terminal<B>, text: &str, style: Style, kind: Kind, ) -> Result<(), B::Error> { let width = terminal.size()?.width as usize; for row in wrap(text, width) { self.emit(Line::styled(row, style), kind); } self.pending_blank = true; Ok(()) }
/// Wraps `text` to the terminal width, preserving each span's style, /// and puts every row of it in the transcript. A span with no style /// of its own picks up the dim tool default; a span a tool styled /// itself — a public roll's bold-white total, say — keeps that style /// instead. pub fn insert_text<B: Backend>( &mut self, terminal: &Terminal<B>, text: Text<'static>, ) -> Result<(), B::Error> { let width = terminal.size()?.width as usize; let default = tool(); for line in wrap_spans(text, width) { let spans: Vec<Span<'static>> = line .spans .into_iter() .map(|span| { if span.style == Style::default() { Span::styled(span.content, span.style.patch(default)) } else { span } }) .collect(); self.emit(Line::from(spans), Kind::Tool); } Ok(()) }
/// Queues one row for the transcript, to go out with the rest of the /// pass's rows. /// /// A row with no characters on it is a paragraph break. It does not /// go out on its own: it only marks that one blank row belongs ahead /// of the next row that has something on it. A change of kind marks /// the same thing, which is what separates the blocks of the /// transcript from each other. The transcript's first row gets no /// blank row ahead of it, whatever kind it is: there is nothing above /// it to separate it from. /// /// The test is for characters, not for columns. A row of characters /// that print in no columns at all, a combining mark that lost its /// letter, is content the transcript carries rather than a break. fn emit(&mut self, row: Line<'static>, kind: Kind) { if row.iter().all(|span| span.content.is_empty()) { self.pending_blank = true; return; } if self.started { self.pending_blank |= self.kind != kind; } self.started = true; self.kind = kind; self.said |= matches!(kind, Kind::Narration | Kind::Tool); let blank = u16::from(self.pending_blank); self.pending_blank = false; self.queued.push(Queued { blank, row }); }}
#[cfg(test)]#[path = "transcript_tests.rs"]mod tests;
#[cfg(test)]#[path = "transcript_width_tests.rs"]mod width_tests;