//! 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, /// 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, /// 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> { 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(&mut self, terminal: &mut Terminal) -> 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( &mut self, terminal: &Terminal, 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(&mut self, terminal: &Terminal) -> 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( &mut self, terminal: &Terminal, 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(&mut self, terminal: &Terminal) -> Result { 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( &mut self, terminal: &Terminal, 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( &mut self, terminal: &Terminal, 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> = 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;