Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324use bevy::asset::io::Reader;use bevy::asset::{AssetLoader, LoadContext};use bevy::prelude::*;use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd, TextMergeStream};
#[derive(Debug, Clone)]pub struct StyledSpan { pub text: String, pub bold: bool, pub italic: bool, pub code: bool,}
#[derive(Debug, Clone)]pub enum SlideFragment { Heading(u8, String), Body(Vec<StyledSpan>), Code(String), Rule, ListItem(Vec<StyledSpan>), /// Local image from assets/ directory. Image { path: String, alt: String }, /// AT URI embed — rendered using the event card pipeline. AtEmbed(String),}
#[derive(Debug, Clone)]pub struct Slide { pub fragments: Vec<SlideFragment>,}
/// Bevy asset: a parsed slide deck loaded from a markdown file.#[derive(Asset, TypePath, Debug, Clone)]pub struct SlideDeckAsset { pub slides: Vec<Slide>,}
/// Resource mirror of the loaded slide deck, updated on asset changes.#[derive(Resource, Default)]pub struct SlideDeck { pub slides: Vec<Slide>,}
/// Handle to the loaded slide deck asset.#[derive(Resource)]pub struct SlideDeckHandle(pub Handle<SlideDeckAsset>);
/// `None` = normal event card, `Some(index)` = showing slide.#[derive(Resource, Default)]pub struct SlideMode { pub active: Option<usize>, pub last_index: usize,}
#[derive(Default, TypePath)]pub struct SlideDeckLoader;
impl AssetLoader for SlideDeckLoader { type Asset = SlideDeckAsset; type Settings = (); type Error = std::io::Error;
async fn load( &self, reader: &mut dyn Reader, _settings: &(), _load_context: &mut LoadContext<'_>, ) -> Result<Self::Asset, Self::Error> { let mut bytes = Vec::new(); reader.read_to_end(&mut bytes).await?; let markdown = String::from_utf8(bytes).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; let slides = parse_slide_deck(&markdown); Ok(SlideDeckAsset { slides }) }
fn extensions(&self) -> &[&str] { &["md"] }}
fn parse_slide(markdown: &str) -> Slide { let options = Options::empty(); let parser = TextMergeStream::new(Parser::new_ext(markdown, options));
let mut fragments = Vec::new(); let mut bold = false; let mut italic = false; let mut in_code_block = false; let mut code_buf = String::new(); let mut spans: Vec<StyledSpan> = Vec::new(); let mut in_heading: Option<u8> = None; let mut heading_text = String::new(); let mut in_list_item = false;
for event in parser { match event { Event::Start(Tag::Heading { level, .. }) => { let lvl = match level { HeadingLevel::H1 => 1, HeadingLevel::H2 => 2, HeadingLevel::H3 => 3, HeadingLevel::H4 => 4, HeadingLevel::H5 => 5, HeadingLevel::H6 => 6, }; in_heading = Some(lvl); heading_text.clear(); } Event::End(TagEnd::Heading(_)) => { if let Some(lvl) = in_heading.take() { fragments.push(SlideFragment::Heading(lvl, heading_text.clone())); heading_text.clear(); } } Event::Start(Tag::Paragraph) => { spans.clear(); } Event::End(TagEnd::Paragraph) => { if !spans.is_empty() { if in_list_item { fragments.push(SlideFragment::ListItem(std::mem::take(&mut spans))); } else { fragments.push(SlideFragment::Body(std::mem::take(&mut spans))); } } } Event::Start(Tag::Emphasis) => italic = true, Event::End(TagEnd::Emphasis) => italic = false, Event::Start(Tag::Strong) => bold = true, Event::End(TagEnd::Strong) => bold = false, Event::Start(Tag::CodeBlock(_)) => { in_code_block = true; code_buf.clear(); } Event::End(TagEnd::CodeBlock) => { in_code_block = false; fragments.push(SlideFragment::Code(code_buf.clone())); code_buf.clear(); } Event::Start(Tag::Item) => { in_list_item = true; spans.clear(); } Event::End(TagEnd::Item) => { if !spans.is_empty() { fragments.push(SlideFragment::ListItem(std::mem::take(&mut spans))); } in_list_item = false; } Event::Start(Tag::List(_)) | Event::End(TagEnd::List(_)) => {} Event::Start(Tag::Image { dest_url, .. }) => { let url = dest_url.to_string(); if url.starts_with("at://") { fragments.push(SlideFragment::AtEmbed(url)); } else { // Collect alt text from subsequent Text events // For now, use empty alt — the End(Image) will fire next fragments.push(SlideFragment::Image { path: url, alt: String::new() }); } } Event::End(TagEnd::Image) => {} Event::Text(text) => { if in_code_block { code_buf.push_str(&text); } else if in_heading.is_some() { heading_text.push_str(&text); } else { spans.push(StyledSpan { text: text.to_string(), bold, italic, code: false, }); } } Event::Code(code) => { if in_heading.is_some() { heading_text.push_str(&code); } else { spans.push(StyledSpan { text: code.to_string(), bold, italic, code: true, }); } } Event::SoftBreak | Event::HardBreak => { if in_heading.is_some() { heading_text.push(' '); } else { spans.push(StyledSpan { text: " ".to_string(), bold: false, italic: false, code: false, }); } } Event::Rule => { fragments.push(SlideFragment::Rule); } _ => {} } }
Slide { fragments }}
/// Split on `---` lines, parse each section as a slide.fn parse_slide_deck(markdown: &str) -> Vec<Slide> { let mut slides = Vec::new(); let mut current = String::new();
for line in markdown.lines() { if line.trim() == "---" { let trimmed = current.trim(); if !trimmed.is_empty() { slides.push(parse_slide(trimmed)); } current.clear(); } else { current.push_str(line); current.push('\n'); } }
let trimmed = current.trim(); if !trimmed.is_empty() { slides.push(parse_slide(trimmed)); }
slides}
/// Startup: kick off the asset load.pub fn load_slide_deck(mut commands: Commands, asset_server: Res<AssetServer>) { let handle = asset_server.load::<SlideDeckAsset>("slides.md"); commands.insert_resource(SlideDeckHandle(handle));}
/// Sync the `SlideDeck` resource when the asset is loaded or hot-reloaded.pub fn sync_slide_deck( mut events: MessageReader<AssetEvent<SlideDeckAsset>>, assets: Res<Assets<SlideDeckAsset>>, handle: Option<Res<SlideDeckHandle>>, mut deck: ResMut<SlideDeck>, mut render_state: ResMut<crate::event_card::CardRenderState>,) { let Some(handle) = handle else { return };
for event in events.read() { let id = match event { AssetEvent::Added { id } | AssetEvent::Modified { id } => id, _ => continue, };
if *id != handle.0.id() { continue; }
if let Some(asset) = assets.get(*id) { let count = asset.slides.len(); deck.slides = asset.slides.clone(); render_state.slide_generation += 1; info!("Slide deck reloaded: {count} slides"); } }}
const SLIDE_TIME_SCALE: f32 = 0.1;
/// Right/N = next, Left/P = prev, Escape = dismiss. Slows time while active.pub fn slide_navigation( keys: Res<ButtonInput<KeyCode>>, deck: Res<SlideDeck>, mut mode: ResMut<SlideMode>, mut helix_state: ResMut<crate::helix::HelixState>,) { if deck.slides.is_empty() { return; }
let advance = keys.just_pressed(KeyCode::ArrowRight) || keys.just_pressed(KeyCode::KeyN); let retreat = keys.just_pressed(KeyCode::ArrowLeft) || keys.just_pressed(KeyCode::KeyP); let dismiss = keys.just_pressed(KeyCode::Escape);
if dismiss { if let Some(i) = mode.active { mode.last_index = i; mode.active = None; helix_state.time_scale = 1.0; info!("Slides dismissed"); } return; }
if advance { let next = match mode.active { None => { helix_state.time_scale = SLIDE_TIME_SCALE; mode.last_index } Some(i) => (i + 1).min(deck.slides.len() - 1), }; mode.active = Some(next); info!("Slide {}/{}", next + 1, deck.slides.len()); }
if retreat { if let Some(i) = mode.active { if i > 0 { mode.active = Some(i - 1); info!("Slide {}/{}", i, deck.slides.len()); } } }}