this repo has no description
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}