this repo has no description
at main 323 lines 10 kB view raw
1use bevy::asset::io::Reader; 2use bevy::asset::{AssetLoader, LoadContext}; 3use bevy::prelude::*; 4use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd, TextMergeStream}; 5 6 7#[derive(Debug, Clone)] 8pub struct StyledSpan { 9 pub text: String, 10 pub bold: bool, 11 pub italic: bool, 12 pub code: bool, 13} 14 15#[derive(Debug, Clone)] 16pub enum SlideFragment { 17 Heading(u8, String), 18 Body(Vec<StyledSpan>), 19 Code(String), 20 Rule, 21 ListItem(Vec<StyledSpan>), 22 /// Local image from assets/ directory. 23 Image { path: String, alt: String }, 24 /// AT URI embed — rendered using the event card pipeline. 25 AtEmbed(String), 26} 27 28#[derive(Debug, Clone)] 29pub struct Slide { 30 pub fragments: Vec<SlideFragment>, 31} 32 33/// Bevy asset: a parsed slide deck loaded from a markdown file. 34#[derive(Asset, TypePath, Debug, Clone)] 35pub struct SlideDeckAsset { 36 pub slides: Vec<Slide>, 37} 38 39/// Resource mirror of the loaded slide deck, updated on asset changes. 40#[derive(Resource, Default)] 41pub struct SlideDeck { 42 pub slides: Vec<Slide>, 43} 44 45/// Handle to the loaded slide deck asset. 46#[derive(Resource)] 47pub struct SlideDeckHandle(pub Handle<SlideDeckAsset>); 48 49/// `None` = normal event card, `Some(index)` = showing slide. 50#[derive(Resource, Default)] 51pub struct SlideMode { 52 pub active: Option<usize>, 53 pub last_index: usize, 54} 55 56 57#[derive(Default, TypePath)] 58pub struct SlideDeckLoader; 59 60impl AssetLoader for SlideDeckLoader { 61 type Asset = SlideDeckAsset; 62 type Settings = (); 63 type Error = std::io::Error; 64 65 async fn load( 66 &self, 67 reader: &mut dyn Reader, 68 _settings: &(), 69 _load_context: &mut LoadContext<'_>, 70 ) -> Result<Self::Asset, Self::Error> { 71 let mut bytes = Vec::new(); 72 reader.read_to_end(&mut bytes).await?; 73 let markdown = 74 String::from_utf8(bytes).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; 75 let slides = parse_slide_deck(&markdown); 76 Ok(SlideDeckAsset { slides }) 77 } 78 79 fn extensions(&self) -> &[&str] { 80 &["md"] 81 } 82} 83 84 85fn parse_slide(markdown: &str) -> Slide { 86 let options = Options::empty(); 87 let parser = TextMergeStream::new(Parser::new_ext(markdown, options)); 88 89 let mut fragments = Vec::new(); 90 let mut bold = false; 91 let mut italic = false; 92 let mut in_code_block = false; 93 let mut code_buf = String::new(); 94 let mut spans: Vec<StyledSpan> = Vec::new(); 95 let mut in_heading: Option<u8> = None; 96 let mut heading_text = String::new(); 97 let mut in_list_item = false; 98 99 for event in parser { 100 match event { 101 Event::Start(Tag::Heading { level, .. }) => { 102 let lvl = match level { 103 HeadingLevel::H1 => 1, 104 HeadingLevel::H2 => 2, 105 HeadingLevel::H3 => 3, 106 HeadingLevel::H4 => 4, 107 HeadingLevel::H5 => 5, 108 HeadingLevel::H6 => 6, 109 }; 110 in_heading = Some(lvl); 111 heading_text.clear(); 112 } 113 Event::End(TagEnd::Heading(_)) => { 114 if let Some(lvl) = in_heading.take() { 115 fragments.push(SlideFragment::Heading(lvl, heading_text.clone())); 116 heading_text.clear(); 117 } 118 } 119 Event::Start(Tag::Paragraph) => { 120 spans.clear(); 121 } 122 Event::End(TagEnd::Paragraph) => { 123 if !spans.is_empty() { 124 if in_list_item { 125 fragments.push(SlideFragment::ListItem(std::mem::take(&mut spans))); 126 } else { 127 fragments.push(SlideFragment::Body(std::mem::take(&mut spans))); 128 } 129 } 130 } 131 Event::Start(Tag::Emphasis) => italic = true, 132 Event::End(TagEnd::Emphasis) => italic = false, 133 Event::Start(Tag::Strong) => bold = true, 134 Event::End(TagEnd::Strong) => bold = false, 135 Event::Start(Tag::CodeBlock(_)) => { 136 in_code_block = true; 137 code_buf.clear(); 138 } 139 Event::End(TagEnd::CodeBlock) => { 140 in_code_block = false; 141 fragments.push(SlideFragment::Code(code_buf.clone())); 142 code_buf.clear(); 143 } 144 Event::Start(Tag::Item) => { 145 in_list_item = true; 146 spans.clear(); 147 } 148 Event::End(TagEnd::Item) => { 149 if !spans.is_empty() { 150 fragments.push(SlideFragment::ListItem(std::mem::take(&mut spans))); 151 } 152 in_list_item = false; 153 } 154 Event::Start(Tag::List(_)) | Event::End(TagEnd::List(_)) => {} 155 Event::Start(Tag::Image { dest_url, .. }) => { 156 let url = dest_url.to_string(); 157 if url.starts_with("at://") { 158 fragments.push(SlideFragment::AtEmbed(url)); 159 } else { 160 // Collect alt text from subsequent Text events 161 // For now, use empty alt — the End(Image) will fire next 162 fragments.push(SlideFragment::Image { path: url, alt: String::new() }); 163 } 164 } 165 Event::End(TagEnd::Image) => {} 166 Event::Text(text) => { 167 if in_code_block { 168 code_buf.push_str(&text); 169 } else if in_heading.is_some() { 170 heading_text.push_str(&text); 171 } else { 172 spans.push(StyledSpan { 173 text: text.to_string(), 174 bold, 175 italic, 176 code: false, 177 }); 178 } 179 } 180 Event::Code(code) => { 181 if in_heading.is_some() { 182 heading_text.push_str(&code); 183 } else { 184 spans.push(StyledSpan { 185 text: code.to_string(), 186 bold, 187 italic, 188 code: true, 189 }); 190 } 191 } 192 Event::SoftBreak | Event::HardBreak => { 193 if in_heading.is_some() { 194 heading_text.push(' '); 195 } else { 196 spans.push(StyledSpan { 197 text: " ".to_string(), 198 bold: false, 199 italic: false, 200 code: false, 201 }); 202 } 203 } 204 Event::Rule => { 205 fragments.push(SlideFragment::Rule); 206 } 207 _ => {} 208 } 209 } 210 211 Slide { fragments } 212} 213 214/// Split on `---` lines, parse each section as a slide. 215fn parse_slide_deck(markdown: &str) -> Vec<Slide> { 216 let mut slides = Vec::new(); 217 let mut current = String::new(); 218 219 for line in markdown.lines() { 220 if line.trim() == "---" { 221 let trimmed = current.trim(); 222 if !trimmed.is_empty() { 223 slides.push(parse_slide(trimmed)); 224 } 225 current.clear(); 226 } else { 227 current.push_str(line); 228 current.push('\n'); 229 } 230 } 231 232 let trimmed = current.trim(); 233 if !trimmed.is_empty() { 234 slides.push(parse_slide(trimmed)); 235 } 236 237 slides 238} 239 240 241/// Startup: kick off the asset load. 242pub fn load_slide_deck(mut commands: Commands, asset_server: Res<AssetServer>) { 243 let handle = asset_server.load::<SlideDeckAsset>("slides.md"); 244 commands.insert_resource(SlideDeckHandle(handle)); 245} 246 247/// Sync the `SlideDeck` resource when the asset is loaded or hot-reloaded. 248pub fn sync_slide_deck( 249 mut events: MessageReader<AssetEvent<SlideDeckAsset>>, 250 assets: Res<Assets<SlideDeckAsset>>, 251 handle: Option<Res<SlideDeckHandle>>, 252 mut deck: ResMut<SlideDeck>, 253 mut render_state: ResMut<crate::event_card::CardRenderState>, 254) { 255 let Some(handle) = handle else { return }; 256 257 for event in events.read() { 258 let id = match event { 259 AssetEvent::Added { id } | AssetEvent::Modified { id } => id, 260 _ => continue, 261 }; 262 263 if *id != handle.0.id() { 264 continue; 265 } 266 267 if let Some(asset) = assets.get(*id) { 268 let count = asset.slides.len(); 269 deck.slides = asset.slides.clone(); 270 render_state.slide_generation += 1; 271 info!("Slide deck reloaded: {count} slides"); 272 } 273 } 274} 275 276const SLIDE_TIME_SCALE: f32 = 0.1; 277 278/// Right/N = next, Left/P = prev, Escape = dismiss. Slows time while active. 279pub fn slide_navigation( 280 keys: Res<ButtonInput<KeyCode>>, 281 deck: Res<SlideDeck>, 282 mut mode: ResMut<SlideMode>, 283 mut helix_state: ResMut<crate::helix::HelixState>, 284) { 285 if deck.slides.is_empty() { 286 return; 287 } 288 289 let advance = keys.just_pressed(KeyCode::ArrowRight) || keys.just_pressed(KeyCode::KeyN); 290 let retreat = keys.just_pressed(KeyCode::ArrowLeft) || keys.just_pressed(KeyCode::KeyP); 291 let dismiss = keys.just_pressed(KeyCode::Escape); 292 293 if dismiss { 294 if let Some(i) = mode.active { 295 mode.last_index = i; 296 mode.active = None; 297 helix_state.time_scale = 1.0; 298 info!("Slides dismissed"); 299 } 300 return; 301 } 302 303 if advance { 304 let next = match mode.active { 305 None => { 306 helix_state.time_scale = SLIDE_TIME_SCALE; 307 mode.last_index 308 } 309 Some(i) => (i + 1).min(deck.slides.len() - 1), 310 }; 311 mode.active = Some(next); 312 info!("Slide {}/{}", next + 1, deck.slides.len()); 313 } 314 315 if retreat { 316 if let Some(i) = mode.active { 317 if i > 0 { 318 mode.active = Some(i - 1); 319 info!("Slide {}/{}", i, deck.slides.len()); 320 } 321 } 322 } 323}