use 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), Code(String), Rule, ListItem(Vec), /// 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, } /// Bevy asset: a parsed slide deck loaded from a markdown file. #[derive(Asset, TypePath, Debug, Clone)] pub struct SlideDeckAsset { pub slides: Vec, } /// Resource mirror of the loaded slide deck, updated on asset changes. #[derive(Resource, Default)] pub struct SlideDeck { pub slides: Vec, } /// Handle to the loaded slide deck asset. #[derive(Resource)] pub struct SlideDeckHandle(pub Handle); /// `None` = normal event card, `Some(index)` = showing slide. #[derive(Resource, Default)] pub struct SlideMode { pub active: Option, 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 { 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 = Vec::new(); let mut in_heading: Option = 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 { 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) { let handle = asset_server.load::("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>, assets: Res>, handle: Option>, mut deck: ResMut, mut render_state: ResMut, ) { 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>, deck: Res, mut mode: ResMut, mut helix_state: ResMut, ) { 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()); } } } }