diff --git a/src/dm/tools/mark.rs b/src/dm/tools/mark.rs --- a/src/dm/tools/mark.rs +++ b/src/dm/tools/mark.rs @@ -9,6 +9,7 @@ use ratatui::text::{Line, Span, Text}; use serde_json::{Value, json}; use crate::campaign::{Campaign, GameTime}; +use crate::markdown; use super::{Tool, ToolReply, Visibility}; @@ -93,22 +94,36 @@ )), } } -/// The public line: the clock Marker, the new time, and the event. +/// The public line: the clock marker and the new time as plain text, then +/// the event rendered as inline markdown, so a wikilink in it shows its +/// bold display form the same way narration does. fn public_line(outcome: &Outcome) -> Text<'static> { - Text::from(Line::from(Span::raw(format!( - "{CLOCK_MARKER} {} — {}", - outcome.time, outcome.event - )))) + Text::from(prefixed(&outcome.time, markdown::line(&outcome.event))) } /// The screened line: the same mark with the event's sensitive details -/// left out, as the DM supplied it. +/// left out, as the DM supplied it, rendered as inline markdown and dimmed +/// over its whole width, prefix and rendered spans alike. fn screened_line(outcome: &Outcome) -> Text<'static> { let shown = outcome.screened.as_deref().unwrap_or("time passes"); - Text::from(Line::from(Span::styled( - format!("{CLOCK_MARKER} {} — {shown}", outcome.time), - dim_style(), - ))) + let mut line = prefixed(&outcome.time, markdown::line(shown)); + line.spans = line.spans.into_iter().map(dim).collect(); + Text::from(line) +} + +/// The clock marker and time as a plain span, followed by a rendered +/// line's own spans. The event text mark validates is always a single +/// line, so `rendered` always holds exactly one line of spans. +fn prefixed(time: &GameTime, rendered: Text<'static>) -> Line<'static> { + let mut spans = vec![Span::raw(format!("{CLOCK_MARKER} {time} — "))]; + spans.extend(rendered.lines.into_iter().next().unwrap_or_default().spans); + Line::from(spans) +} + +/// Patches the dim modifier onto a span's existing style, keeping +/// whatever styling the markdown renderer already gave it. +fn dim(span: Span<'static>) -> Span<'static> { + Span::styled(span.content, span.style.patch(dim_style())) } fn dim_style() -> Style { diff --git a/src/dm/tools/mark_tests.rs b/src/dm/tools/mark_tests.rs --- a/src/dm/tools/mark_tests.rs +++ b/src/dm/tools/mark_tests.rs @@ -31,7 +31,10 @@ assert!(reply.for_model.contains("campaign log and the transcript")); assert_eq!( reply.public, - Text::raw("⏰ #d1-0830 — The party leaves the inn.") + Text::from(Line::from(vec![ + Span::raw("⏰ #d1-0830 — "), + Span::raw("The party leaves the inn."), + ])) ); let world = dir.path(); assert!(world.join("campaign-log/0001.md").exists()); @@ -39,6 +42,50 @@ assert!(world.join("transcript/0001.md").exists()); } #[test] +fn a_piped_wikilink_shows_its_display_text_in_bold() { + let (mut tool, _dir) = campaign_tool(); + + let reply = tool + .call( + &json!({ + "time": "#d1-0830", + "event": "[[people/angus-pettigrew|Old Pettigrew]] begs for help.", + }), + Visibility::Public, + ) + .unwrap(); + + let rendered = reply.public.to_string(); + assert!(rendered.contains("Old Pettigrew")); + assert!(!rendered.contains("[[")); + let wiki_span = reply.public.lines[0] + .spans + .iter() + .find(|span| span.content == "Old Pettigrew") + .expect("the wikilink's display text renders as its own span"); + assert!(wiki_span.style.add_modifier.contains(Modifier::BOLD)); +} + +#[test] +fn a_bare_wikilink_shows_its_last_segment() { + let (mut tool, _dir) = campaign_tool(); + + let reply = tool + .call( + &json!({ + "time": "#d1-0830", + "event": "The party arrives at [[places/weatherford/old-mill]].", + }), + Visibility::Public, + ) + .unwrap(); + + let rendered = reply.public.to_string(); + assert!(rendered.contains("old-mill")); + assert!(!rendered.contains("places/weatherford")); +} + +#[test] fn a_secret_mark_writes_only_to_the_log() { let (mut tool, dir) = campaign_tool(); @@ -74,14 +121,38 @@ .unwrap(); assert_eq!( reply.screened, - Text::from(Line::from(Span::styled( - "⏰ #d1-0830 — Something stirs in the shadows.", - dim_style() - ))) + Text::from(Line::from(vec![ + Span::styled("⏰ #d1-0830 — ", dim_style()), + Span::styled("Something stirs in the shadows.", dim_style()), + ])) ); let transcript = std::fs::read_to_string(dir.path().join("transcript/0001.md")).unwrap(); assert!(transcript.contains("Something stirs in the shadows.")); assert!(!transcript.contains("d20")); +} + +#[test] +fn a_screened_line_with_a_wikilink_is_dim_across_every_span() { + let (mut tool, _dir) = campaign_tool(); + + let reply = tool + .call( + &json!({ + "time": "#d1-0830", + "event": "A spy rolls a d20 for stealth.", + "screened": "[[people/mother-lena|Mother Lena]] senses something is wrong.", + }), + Visibility::Screened, + ) + .unwrap(); + + let spans = &reply.screened.lines[0].spans; + assert!(spans.len() > 1); + assert!( + spans + .iter() + .all(|span| span.style.add_modifier.contains(Modifier::DIM)) + ); } #[test] diff --git a/src/markdown/markdown_tests.rs b/src/markdown/markdown_tests.rs --- a/src/markdown/markdown_tests.rs +++ b/src/markdown/markdown_tests.rs @@ -33,6 +33,22 @@ assert_eq!(rows("", 40), Vec::>::new()); } #[test] +fn a_line_renders_its_wikilink_in_display_form() { + assert_eq!( + line("[[people/angus-pettigrew|Old Pettigrew]] begs for help"), + Text::from(Line::from(vec![ + Span::styled("Old Pettigrew", style::wiki()), + Span::raw(" begs for help"), + ])) + ); +} + +#[test] +fn a_line_with_no_block_renders_empty() { + assert_eq!(line(""), Text::default()); +} + +#[test] fn a_heading_renders_in_its_level_style() { assert_eq!( rows("# Title", 40), diff --git a/src/markdown/mod.rs b/src/markdown/mod.rs --- a/src/markdown/mod.rs +++ b/src/markdown/mod.rs @@ -17,7 +17,8 @@ pub use stream::MarkdownStream; use pulldown_cmark::{Event, Options, Parser, Tag}; -use ratatui::text::Line; +use ratatui::style::Style; +use ratatui::text::{Line, Text}; /// Renders a complete markdown document to styled rows at `width`. /// Blocks are separated by one empty row. @@ -25,6 +26,23 @@ pub fn rows(markdown: &str, width: u16) -> Vec> { let width = width as usize; let mut events = Events::new(markdown); join(block::document(&mut events, width)) +} + +/// Renders one line of inline markdown, such as a tool's event text, with +/// no block wrapping and no line-width limit. +/// +/// `markdown` still parses as a full document, so a leading `#` opens a +/// heading and a leading `-` opens a list, but only that one block's +/// inline content renders: a wikilink shows its bold display form, and +/// emphasis and a code span render the same way they do inside narration. +/// Text with no block of its own, such as an empty string or a bare rule, +/// renders as an empty line. +pub(crate) fn line(markdown: &str) -> Text<'static> { + let mut events = Events::new(markdown); + match events.next() { + Some(Event::Start(tag)) => inline::text(&mut events, tag.to_end(), Style::default()), + _ => Text::default(), + } } /// The prefix of `markdown` that renders the same however much text