this repo has no description

polish

Orual 57be2114 b2da9c1c

+406 -988
+33 -33
Cargo.lock
··· 5192 5192 ] 5193 5193 5194 5194 [[package]] 5195 - name = "jacquard-talk" 5196 - version = "0.1.0" 5197 - dependencies = [ 5198 - "async-channel 2.5.0", 5199 - "async-tungstenite", 5200 - "bevy", 5201 - "bevy-tokio-tasks", 5202 - "bevy_brp_extras", 5203 - "bevy_hanabi", 5204 - "bevy_panorbit_camera", 5205 - "bytes", 5206 - "crossbeam-channel", 5207 - "crossbeam-queue", 5208 - "futures-timer", 5209 - "futures-util", 5210 - "http", 5211 - "jacquard", 5212 - "jacquard-common", 5213 - "log", 5214 - "pulldown-cmark", 5215 - "rand 0.10.0", 5216 - "rand_chacha 0.10.0", 5217 - "reqwest", 5218 - "rusqlite", 5219 - "serde", 5220 - "serde_json", 5221 - "smol", 5222 - "smol_str 0.3.6", 5223 - "surf", 5224 - "tokio", 5225 - ] 5226 - 5227 - [[package]] 5228 5195 name = "jni" 5229 5196 version = "0.21.1" 5230 5197 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5624 5591 checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" 5625 5592 dependencies = [ 5626 5593 "libc", 5594 + ] 5595 + 5596 + [[package]] 5597 + name = "magic" 5598 + version = "0.1.0" 5599 + dependencies = [ 5600 + "async-channel 2.5.0", 5601 + "async-tungstenite", 5602 + "bevy", 5603 + "bevy-tokio-tasks", 5604 + "bevy_brp_extras", 5605 + "bevy_hanabi", 5606 + "bevy_panorbit_camera", 5607 + "bytes", 5608 + "crossbeam-channel", 5609 + "crossbeam-queue", 5610 + "futures-timer", 5611 + "futures-util", 5612 + "http", 5613 + "jacquard", 5614 + "jacquard-common", 5615 + "log", 5616 + "pulldown-cmark", 5617 + "rand 0.10.0", 5618 + "rand_chacha 0.10.0", 5619 + "reqwest", 5620 + "rusqlite", 5621 + "serde", 5622 + "serde_json", 5623 + "smol", 5624 + "smol_str 0.3.6", 5625 + "surf", 5626 + "tokio", 5627 5627 ] 5628 5628 5629 5629 [[package]]
+1 -1
Cargo.toml
··· 1 1 [package] 2 - name = "jacquard-talk" 2 + name = "magic" 3 3 version = "0.1.0" 4 4 edition = "2024" 5 5
+49
Dioxus.toml
··· 1 + [application] 2 + 3 + # Web `build` & `serve` dist path 4 + out_dir = "dist" 5 + 6 + # resource (static) file folder 7 + asset_dir = "public" 8 + 9 + [web.wasm_opt] 10 + # The level wasm-opt should target. z is the smallest. 4 is the fastest. 11 + level = "4" 12 + 13 + [web.app] 14 + 15 + # HTML title tag content 16 + title = "Dioxus | An elegant GUI library for Rust" 17 + 18 + [web.watcher] 19 + 20 + index_on_404 = true 21 + 22 + watch_path = ["src", "examples"] 23 + 24 + [bundle] 25 + # Bundle identifier 26 + identifier = "io.github.magic" 27 + 28 + # Bundle publisher 29 + publisher = "magic" 30 + 31 + # Bundle icon 32 + icon = ["icons/icon.png"] 33 + 34 + # Bundle resources 35 + resources = ["public/*"] 36 + 37 + # Bundle copyright 38 + copyright = "" 39 + 40 + # Bundle category 41 + category = "Utility" 42 + 43 + # Bundle short description 44 + short_description = "An amazing dioxus application." 45 + 46 + # Bundle long description 47 + long_description = """ 48 + An amazing dioxus application. 49 + """
+66 -16
assets/slides.md
··· 1 - # Jacquard Talk 1 + # Jacquard Magic 2 + 3 + Making atproto actually easy with Rust 4 + 5 + --- 6 + 7 + ## Spite-driven development 8 + 9 + - Existing atproto libraries kinda sucked to work with 10 + - Couple of notable exceptions, but none in Rust 11 + - Put a project on hold because I got frustrated with atrium 12 + - Friends working on atproto stuff in Rust had similar frustrations 13 + 14 + --- 15 + 16 + ## Code Generation 17 + 18 + - Going from lexicon to app, generate API bindings 19 + - Then spend way more time (or LLM tokens) writing code around them to make them usable 20 + 21 + --- 2 22 3 - Real-time 3D visualization of the AT Protocol firehose 23 + Not just an atproto problem, but hurts more here, especially for those not wanting to just play in Bluesky's sandbox. 4 24 5 25 --- 6 26 7 - ## What is the AT Protocol? 27 + # We don't have to live like this 8 28 9 - - A federated social networking protocol 10 - - **Jetstream** provides a real-time WebSocket firehose 11 - - Every post, like, follow, and identity event flows through it 29 + 30 + --- 31 + 32 + ## Why Rust? 33 + 34 + Only language where you can actually run it anywhere 35 + 36 + and it's not a stupid idea to. 37 + 38 + --- 39 + 40 + ## Why Rust? 41 + 42 + - Rust has a reputation as a difficult and demanding language 43 + - That reputation is sort of deserved 44 + 45 + 46 + --- 47 + 48 + ## Why Rust? 49 + 50 + - The things Rust asks of you are things you often need to think about anyway 51 + - It just makes them explicit 52 + - We can make the sharp edges easier, if we care to 53 + 54 + --- 55 + 56 + # We don't have to live like this! 12 57 13 58 --- 14 59 15 60 ## What you're seeing 16 61 17 - - Each **particle** is a real event from the network 18 - - Blue = posts, pink = likes, green = follows 19 - - Gold = non-Bluesky AT Protocol events 20 - - The helix maps **time** to **space** 62 + - Each dot is a Jetstream event 63 + - When you select one, we hit 64 + - Constellation for interactions 65 + - Slingshot for records 66 + - Bluesky appview for profiles 21 67 22 68 --- 23 69 24 - ## The Stack 70 + # But... 25 71 26 - - **Bevy** for 3D rendering and ECS 27 - - **Jacquard** for AT Protocol types 28 - - **bevy_hanabi** for GPU particle effects 29 - - **SQLite** for offline replay 30 - - All async runs on **smol**, not tokio 72 + 73 + --- 74 + 75 + ## HOW? 76 + 77 + - Standing upon the shoulders of giants here 78 + - Big party trick is the **subsecond** library 79 + - Bevy engine with hot patch support 80 + 31 81 32 82 --- 33 83
-41
src/db.rs
··· 1 1 //! SQLite persistence layer for Jetstream events. 2 - //! 3 - //! Two dedicated OS threads, each with its own connection to the same DB file: 4 - //! - **Writer thread**: handles batch inserts. Never blocks on reads. 5 - //! - **Reader thread**: handles queries. Never blocks on writes. 6 - //! 7 - //! SQLite WAL mode allows concurrent reads alongside a single writer. 8 - //! Splitting the threads eliminates read/write contention that caused 9 - //! periodic hitching when both operations shared a single thread. 10 2 11 3 use bevy::prelude::*; 12 4 use crossbeam_channel::{Receiver, Sender}; 13 5 use jacquard::types::string::{Did, Nsid}; 14 6 use rusqlite::{Connection, params}; 15 7 16 - // --------------------------------------------------------------------------- 17 - // Request / response types 18 - // --------------------------------------------------------------------------- 19 - 20 - /// Write requests — handled by the dedicated writer thread. 21 8 pub enum DbWriteRequest { 22 9 WriteEvents(Vec<DbEvent>), 23 10 } 24 11 25 - /// Read requests — handled by the dedicated reader thread. 26 12 pub enum DbReadRequest { 27 13 QueryTimeRange { 28 14 start_us: i64, ··· 43 29 pub message_json: String, 44 30 } 45 31 46 - /// Combined resource for backward compatibility with code that uses `DbChannel`. 47 32 #[derive(Resource, Clone)] 48 33 pub struct DbChannel { 49 34 pub writer: Sender<DbWriteRequest>, 50 35 pub reader: Sender<DbReadRequest>, 51 36 } 52 37 53 - // --------------------------------------------------------------------------- 54 - // Schema 55 - // --------------------------------------------------------------------------- 56 - 57 38 const CREATE_EVENTS_TABLE: &str = " 58 39 CREATE TABLE IF NOT EXISTS events ( 59 40 id INTEGER PRIMARY KEY, ··· 71 52 "CREATE INDEX IF NOT EXISTS idx_events_collection ON events(collection);"; 72 53 const CREATE_IDX_DID: &str = "CREATE INDEX IF NOT EXISTS idx_events_did ON events(did);"; 73 54 74 - // --------------------------------------------------------------------------- 75 - // Startup 76 - // --------------------------------------------------------------------------- 77 - 78 55 pub fn spawn_db_thread(mut commands: Commands) { 79 56 let (write_tx, write_rx) = crossbeam_channel::bounded::<DbWriteRequest>(256); 80 57 let (read_tx, read_rx) = crossbeam_channel::bounded::<DbReadRequest>(64); 81 58 82 - // Writer thread — dedicated connection for inserts 83 59 std::thread::spawn(move || { 84 60 run_writer(write_rx); 85 61 }); 86 62 87 - // Reader thread — dedicated connection for queries 88 63 std::thread::spawn(move || { 89 64 run_reader(read_rx); 90 65 }); ··· 96 71 info!("SQLite writer + reader threads started"); 97 72 } 98 73 99 - // --------------------------------------------------------------------------- 100 - // Writer thread 101 - // --------------------------------------------------------------------------- 102 - 103 74 fn run_writer(rx: Receiver<DbWriteRequest>) { 104 75 let conn = match open_and_init() { 105 76 Some(c) => c, ··· 121 92 } 122 93 } 123 94 124 - // --------------------------------------------------------------------------- 125 - // Reader thread 126 - // --------------------------------------------------------------------------- 127 - 128 95 fn run_reader(rx: Receiver<DbReadRequest>) { 129 96 let conn = match open_and_init() { 130 97 Some(c) => c, ··· 166 133 } 167 134 } 168 135 169 - // --------------------------------------------------------------------------- 170 - // Shared helpers 171 - // --------------------------------------------------------------------------- 172 - 173 136 fn open_and_init() -> Option<Connection> { 174 137 let conn = match Connection::open("jetstream_events.db") { 175 138 Ok(c) => c, ··· 216 179 conn.execute_batch(CREATE_IDX_DID)?; 217 180 Ok(()) 218 181 } 219 - 220 - // --------------------------------------------------------------------------- 221 - // SQL operations 222 - // --------------------------------------------------------------------------- 223 182 224 183 fn write_events(conn: &Connection, events: &[DbEvent]) -> rusqlite::Result<()> { 225 184 if events.is_empty() {
+78 -154
src/event_card.rs
··· 1 + use bevy::asset::Handle; 2 + use bevy::image::{ImageFormatSetting, ImageLoaderSettings}; 1 3 use bevy::prelude::*; 2 4 3 5 use jacquard::Data; 4 6 use jacquard::jetstream::JetstreamMessage; 5 7 use jacquard::types::collection::Collection; 6 - use jacquard::types::string::Nsid; 8 + use jacquard::types::string::{AtUri, Nsid}; 9 + 10 + use jacquard::client::AgentSessionExt; 7 11 8 12 use crate::ingest::JetstreamEventMarker; 9 13 use crate::interaction::SelectedEvent; 10 - 11 - // --------------------------------------------------------------------------- 12 - // Marker components — one per UI section so the update system can target them 13 - // --------------------------------------------------------------------------- 14 + use smol_str::ToSmolStr; 14 15 15 16 /// Root node of the event detail card. 16 17 #[derive(Component)] ··· 116 117 pub last_record_gen: u64, 117 118 /// Tracks which slide was last rendered (for change detection). 118 119 pub last_slide_index: Option<usize>, 120 + /// Bumped when slide content changes (hot-reload), forces re-render. 121 + pub slide_generation: u64, 122 + pub last_slide_generation: u64, 119 123 } 120 124 121 - // --------------------------------------------------------------------------- 122 - // Content tree: semantic representation of card content 123 - // --------------------------------------------------------------------------- 124 - 125 125 /// Top-level content produced by dispatch_card_content. 126 126 pub enum CardContent { 127 127 Post { ··· 209 209 210 210 impl CardContent { 211 211 /// Flatten the content tree into a primary display string. 212 - /// Used for the main content text area. 213 - /// Primary text for the content area. Empty for types that use the subject section. 214 212 pub fn primary_text(&self) -> String { 215 213 match self { 216 214 CardContent::Post { text, .. } => text.clone(), ··· 287 285 } 288 286 } 289 287 290 - // --------------------------------------------------------------------------- 291 - // UI setup 292 - // --------------------------------------------------------------------------- 293 - 294 - /// Startup system: spawn the detail card UI hierarchy. 295 - /// 296 - /// The card starts hidden (`Display::None`) and is shown by `update_detail_card` 297 - /// (Task 3) when an event is selected. The card is positioned absolute in the 298 - /// top-right of the screen. 288 + /// Spawn the detail card UI hierarchy. Starts hidden. 299 289 /// 300 - /// Hierarchy: 301 - /// ``` 302 - /// Card container (absolute, right:20, top:20, 400px wide, dark background) 303 - /// ├── Author row (horizontal flex, align_items: Center) 304 - /// │ ├── Avatar image (48x48, grey placeholder) 305 - /// │ └── Name column (vertical flex) 306 - /// │ ├── Display name (white, 16px) 307 - /// │ └── Handle (grey, 13px) 308 - /// ├── Content area (top padding 4px) 309 - /// │ └── Post text (white, 14px, wrapping) 310 - /// ├── Collection label (grey, 11px) 311 - /// └── Timestamp label (grey, 11px) 290 + /// ```text 291 + /// Card (absolute, right:16, top:16, 520px) 292 + /// ├── Author row (avatar + name column) 293 + /// ├── Content text 294 + /// ├── Embed section (images / quoted post / external link) 295 + /// ├── Subject section (liked post / followed user) 296 + /// ├── Stats row 297 + /// ├── Footer (collection label + timestamp) 312 298 /// ``` 313 299 pub fn setup_detail_card(mut commands: Commands, font: Res<crate::UiFont>) { 314 300 // Blueprint palette — cyan glow ··· 375 361 ..default() 376 362 }) 377 363 .with_children(|col| { 378 - col.spawn(( 379 - DisplayNameText, 380 - Text::new("—"), 381 - make_font(13.0), 382 - bright, 383 - )); 384 - col.spawn(( 385 - HandleText, 386 - Text::new("—"), 387 - make_font(11.0), 388 - dim, 389 - )); 364 + col.spawn((DisplayNameText, Text::new("—"), make_font(13.0), bright)); 365 + col.spawn((HandleText, Text::new("—"), make_font(11.0), dim)); 390 366 }); 391 367 }); 392 368 ··· 454 430 BorderColor::all(border_color), 455 431 )) 456 432 .with_children(|qp| { 457 - qp.spawn(( 458 - QuotedPostAuthor, 459 - Text::new(""), 460 - make_font(10.0), 461 - dim, 462 - )); 433 + qp.spawn((QuotedPostAuthor, Text::new(""), make_font(10.0), dim)); 463 434 qp.spawn(( 464 435 QuotedPostText, 465 436 Text::new(""), ··· 494 465 }, 495 466 ImageNode::default(), 496 467 )); 497 - ext.spawn(( 498 - ExternalLinkTitle, 499 - Text::new(""), 500 - make_font(11.0), 501 - bright, 502 - )); 468 + ext.spawn((ExternalLinkTitle, Text::new(""), make_font(11.0), bright)); 503 469 ext.spawn(( 504 470 ExternalLinkDescription, 505 471 Text::new(""), ··· 524 490 BorderColor::all(border_color), 525 491 )) 526 492 .with_children(|subj| { 527 - subj.spawn(( 528 - SubjectLabel, 529 - Text::new(""), 530 - make_font(10.0), 531 - dim, 532 - )); 533 - subj.spawn(( 534 - SubjectAuthor, 535 - Text::new(""), 536 - make_font(10.0), 537 - dim, 538 - )); 493 + subj.spawn((SubjectLabel, Text::new(""), make_font(10.0), dim)); 494 + subj.spawn((SubjectAuthor, Text::new(""), make_font(10.0), dim)); 539 495 subj.spawn(( 540 496 SubjectContent, 541 497 Text::new(""), ··· 554 510 }, 555 511 )) 556 512 .with_children(|stats| { 557 - stats.spawn(( 558 - StatsText, 559 - Text::new(""), 560 - make_font(10.0), 561 - dim, 562 - )); 513 + stats.spawn((StatsText, Text::new(""), make_font(10.0), dim)); 563 514 }); 564 515 565 516 // --- Separator --- ··· 579 530 ..default() 580 531 }) 581 532 .with_children(|footer| { 582 - footer.spawn(( 583 - CollectionLabel, 584 - Text::new(""), 585 - make_font(10.0), 586 - dim, 587 - )); 588 - footer.spawn(( 589 - TimestampLabel, 590 - Text::new(""), 591 - make_font(10.0), 592 - dim, 593 - )); 533 + footer.spawn((CollectionLabel, Text::new(""), make_font(10.0), dim)); 534 + footer.spawn((TimestampLabel, Text::new(""), make_font(10.0), dim)); 594 535 }); 595 536 596 537 // --- Slide content container (hidden by default) --- ··· 615 556 let last_slide = world.resource::<CardRenderState>().last_slide_index; 616 557 617 558 if let Some(index) = slide_index { 618 - if last_slide == Some(index) { 559 + let slide_gen = world.resource::<CardRenderState>().slide_generation; 560 + let last_slide_gen = world.resource::<CardRenderState>().last_slide_generation; 561 + if last_slide == Some(index) && slide_gen == last_slide_gen { 619 562 return; 620 563 } 564 + world 565 + .resource_mut::<CardRenderState>() 566 + .last_slide_generation = slide_gen; 621 567 world.resource_mut::<CardRenderState>().last_slide_index = Some(index); 622 568 623 569 let slide = { ··· 781 727 // Label based on collection 782 728 let label = match &event.0 { 783 729 JetstreamMessage::Commit { commit, .. } => { 784 - let short = commit.collection.as_str().rsplit('.').next() 730 + let short = commit 731 + .collection 732 + .as_str() 733 + .rsplit('.') 734 + .next() 785 735 .unwrap_or(commit.collection.as_str()); 786 736 // capitalize first letter 787 737 let mut label = short.to_string(); ··· 830 780 world.resource_mut::<SelectedEvent>().previous_entity = Some(entity); 831 781 } 832 782 833 - // --------------------------------------------------------------------------- 834 - // World helpers for the exclusive system 835 - // --------------------------------------------------------------------------- 836 - 837 783 fn set_text<M: Component>(world: &mut World, value: &str) { 838 784 let mut q = world.query_filtered::<&mut Text, With<M>>(); 839 785 for mut text in q.iter_mut(world) { ··· 848 794 } 849 795 } 850 796 851 - // --------------------------------------------------------------------------- 852 - // Slide presentation helpers 853 - // --------------------------------------------------------------------------- 854 - 855 797 fn set_card_presentation_mode(world: &mut World, presentation: bool) { 856 798 let mut q = world.query_filtered::<&mut Node, With<DetailCard>>(); 857 799 for mut node in q.iter_mut(world) { ··· 972 914 .id(); 973 915 world.entity_mut(row).add_child(bullet); 974 916 975 - let text_node = 976 - spawn_styled_spans(world, &font_handle, spans, 28.0, bright, dim); 917 + let text_node = spawn_styled_spans(world, &font_handle, spans, 28.0, bright, dim); 977 918 world.entity_mut(row).add_child(text_node); 978 919 979 920 world.entity_mut(content_entity).add_child(row); ··· 1020 961 1021 962 fn spawn_styled_spans( 1022 963 world: &mut World, 1023 - font: &bevy::asset::Handle<bevy::text::Font>, 964 + font: &Handle<bevy::text::Font>, 1024 965 spans: &[crate::slides::StyledSpan], 1025 966 font_size: f32, 1026 967 bright: Color, ··· 1045 986 .id() 1046 987 } 1047 988 1048 - fn load_cdn_image(world: &World, did: &str, cid: &str) -> bevy::asset::Handle<Image> { 989 + fn load_cdn_image(world: &World, did: &str, cid: &str) -> Handle<Image> { 1049 990 let url = bsky_cdn_url(did, cid); 1050 991 let server = world.resource::<AssetServer>(); 1051 - server.load_with_settings::<Image, bevy::image::ImageLoaderSettings>( 992 + server.load_with_settings::<Image, ImageLoaderSettings>( 1052 993 url, 1053 - |settings: &mut bevy::image::ImageLoaderSettings| { 1054 - settings.format = bevy::image::ImageFormatSetting::Guess; 994 + |settings: &mut ImageLoaderSettings| { 995 + settings.format = ImageFormatSetting::Guess; 1055 996 }, 1056 997 ) 1057 998 } ··· 1528 1469 } 1529 1470 } 1530 1471 1531 - 1532 - 1533 - 1534 - 1535 1472 /// Build a bsky CDN URL for an image blob. 1536 1473 pub fn bsky_cdn_url(did: &str, cid: &str) -> String { 1537 1474 format!("https://cdn.bsky.app/img/feed_thumbnail/plain/{did}/{cid}@jpeg") ··· 1546 1483 pub struct CachedProfile { 1547 1484 pub display_name: Option<String>, 1548 1485 pub handle: String, 1549 - pub avatar_handle: Option<bevy::asset::Handle<Image>>, 1486 + pub avatar_handle: Option<Handle<Image>>, 1550 1487 } 1551 1488 1552 1489 /// Resource: cache of resolved profiles, keyed by DID. ··· 1558 1495 } 1559 1496 1560 1497 impl ProfileCache { 1561 - pub fn bump(&mut self) { self.generation += 1; } 1498 + pub fn bump(&mut self) { 1499 + self.generation += 1; 1500 + } 1562 1501 } 1563 1502 1564 1503 /// Tracks debounce state for profile requests. ··· 1568 1507 pub last_did_change: f32, 1569 1508 } 1570 1509 1571 - // --------------------------------------------------------------------------- 1572 - // Record cache: fetched records and engagement stats 1573 - // --------------------------------------------------------------------------- 1574 - 1575 1510 /// A cached AT Protocol record fetched via slingshot. 1576 1511 pub struct CachedRecord { 1577 1512 pub data: Data, ··· 1595 1530 } 1596 1531 1597 1532 impl RecordCache { 1598 - pub fn bump(&mut self) { self.generation += 1; } 1533 + pub fn bump(&mut self) { 1534 + self.generation += 1; 1535 + } 1599 1536 } 1600 - 1601 - // --------------------------------------------------------------------------- 1602 - // Constellation XRPC types 1603 - // --------------------------------------------------------------------------- 1604 1537 1605 1538 use jacquard::{BosStr, DefaultStr, IntoStatic, XrpcRequest}; 1606 1539 use serde::{Deserialize, Serialize}; ··· 1905 1838 Ok(response) => match response.into_output() { 1906 1839 Ok(output) => { 1907 1840 let profile = output.value; 1908 - let display_name: Option<String> = profile.display_name.as_ref().map(|s| { 1909 - let s: &str = s.as_ref(); 1910 - s.to_owned() 1911 - }); 1841 + let display_name: Option<String> = 1842 + profile.display_name.as_ref().map(|s| { 1843 + let s: &str = s.as_ref(); 1844 + s.to_owned() 1845 + }); 1912 1846 let handle = profile.handle.as_str().to_owned(); 1913 1847 let avatar_url = profile.avatar.as_ref().map(|uri| { 1914 1848 let mut url = uri.as_str().to_owned(); ··· 1930 1864 Some((display_name, handle, avatar_url)) => { 1931 1865 let avatar_handle = avatar_url.as_ref().and_then(|url| { 1932 1866 world.get_resource::<AssetServer>().map(|server| { 1933 - use bevy::image::{ImageFormatSetting, ImageLoaderSettings}; 1867 + use bevy::image::{ 1868 + ImageFormatSetting, ImageLoaderSettings, 1869 + }; 1934 1870 server.load_with_settings::<Image, ImageLoaderSettings>( 1935 1871 url.clone(), 1936 1872 |settings: &mut ImageLoaderSettings| { ··· 1940 1876 }) 1941 1877 }); 1942 1878 let mut cache = world.resource_mut::<ProfileCache>(); 1943 - cache.profiles.insert(did_main, CachedProfile { 1944 - display_name, handle, avatar_handle, 1945 - }); 1879 + cache.profiles.insert( 1880 + did_main, 1881 + CachedProfile { 1882 + display_name, 1883 + handle, 1884 + avatar_handle, 1885 + }, 1886 + ); 1946 1887 cache.bump(); 1947 1888 } 1948 1889 None => { 1949 - cache.profiles.insert(did_main, CachedProfile { 1950 - display_name: None, handle: String::new(), avatar_handle: None, 1951 - }); 1890 + cache.profiles.insert( 1891 + did_main, 1892 + CachedProfile { 1893 + display_name: None, 1894 + handle: String::new(), 1895 + avatar_handle: None, 1896 + }, 1897 + ); 1952 1898 } 1953 1899 } 1954 - }).await; 1900 + }) 1901 + .await; 1955 1902 }); 1956 1903 } 1957 1904 } ··· 1973 1920 let uri_clone = uri.clone(); 1974 1921 1975 1922 runtime.spawn_background_task(move |mut ctx| async move { 1976 - use jacquard::client::AgentSessionExt; 1977 - 1978 - use smol_str::ToSmolStr; 1979 - 1980 - let parsed = match jacquard::types::string::AtUri::new(uri_clone.to_smolstr()) { 1923 + let parsed = match AtUri::new(uri_clone.to_smolstr()) { 1981 1924 Ok(u) => u, 1982 1925 Err(e) => { 1983 1926 warn!("invalid AT-URI {uri_clone}: {e:?}"); ··· 2092 2035 } 2093 2036 } 2094 2037 2095 - // --------------------------------------------------------------------------- 2096 - // Tests 2097 - // --------------------------------------------------------------------------- 2098 - 2099 2038 #[cfg(test)] 2100 2039 mod tests { 2101 2040 use super::*; ··· 2104 2043 }; 2105 2044 use jacquard::types::string::{Datetime, Did, Nsid, Rkey}; 2106 2045 2107 - // ----------------------------------------------------------------------- 2108 - // Helpers 2109 - // ----------------------------------------------------------------------- 2110 2046 2111 2047 fn make_post_marker(text: &str) -> JetstreamEventMarker { 2112 2048 let did = Did::new_static("did:plc:testuser123456789").expect("valid DID"); ··· 2240 2176 }) 2241 2177 } 2242 2178 2243 - // ----------------------------------------------------------------------- 2244 - // dispatch_card_content 2245 - // ----------------------------------------------------------------------- 2246 2179 2247 2180 #[test] 2248 2181 fn dispatch_routes_post_to_text_content() { ··· 2316 2249 } 2317 2250 } 2318 2251 2319 - // ----------------------------------------------------------------------- 2320 - // build_post_content 2321 - // ----------------------------------------------------------------------- 2322 2252 2323 2253 #[test] 2324 2254 fn post_extracts_text_field() { ··· 2353 2283 } 2354 2284 } 2355 2285 2356 - // ----------------------------------------------------------------------- 2357 - // build_unknown_content 2358 - // ----------------------------------------------------------------------- 2359 2286 2360 2287 #[test] 2361 2288 fn unknown_shows_collection_nsid() { ··· 2417 2344 } 2418 2345 } 2419 2346 2420 - // ----------------------------------------------------------------------- 2421 - // embed parsing 2422 - // ----------------------------------------------------------------------- 2423 2347 2424 2348 #[test] 2425 2349 fn parse_image_embed() {
+7 -92
src/helix.rs
··· 1 1 use bevy::prelude::*; 2 2 3 - /// A Frenet-Serret frame: tangent, normal, binormal at a point on the helix. 3 + /// Frenet-Serret frame at a point on the helix. 4 4 #[derive(Debug, Clone, Copy)] 5 5 pub struct Frame { 6 6 pub position: Vec3, 7 - /// Tangent vector along the curve. Reserved for #[hot] use (e.g. particle 8 - /// orientation) — suppress dead_code while the feature is in progress. 9 7 #[allow(dead_code)] 10 8 pub tangent: Vec3, 11 9 pub normal: Vec3, 12 10 pub binormal: Vec3, 13 11 } 14 12 15 - /// Describes one level of the nested helix hierarchy. 16 13 #[derive(Debug, Clone)] 17 14 pub struct HelixLevel { 18 - /// How many times this level wraps around its parent per unit of t. 19 15 pub turns_per_parent: f32, 20 - /// How many sample points to evaluate per turn for rendering. 21 16 pub samples_per_turn: u32, 22 - /// Display label for this level. Reserved for #[hot] UI overlays — suppress 23 - /// dead_code while the feature is in progress. 24 17 #[allow(dead_code)] 25 18 pub label: &'static str, 26 - /// Color for gizmo rendering. 27 19 pub color: Color, 28 - /// Extra multiplier on this level's coil radius. Default 1.0. 29 20 pub radius_scale: f32, 30 21 } 31 22 32 - /// The full hierarchy of nested helix levels. 33 - /// Outermost level is index 0 (hours), innermost is last (sub-second). 23 + /// Outermost level is index 0, innermost is last. 34 24 #[derive(Resource, Debug, Clone)] 35 25 pub struct HelixHierarchy { 36 26 pub levels: Vec<HelixLevel>, 37 - /// Fraction of parent radius used for child coil radius. 38 27 pub fill_factor: f32, 39 - /// Base radius of the outermost helix. 40 28 pub base_radius: f32, 41 - /// Vertical spacing per turn of the outermost helix (units per 1.0 of t). 42 29 pub pitch_per_turn: f32, 43 30 } 44 31 45 - /// Current focal time and active zoom level for the helix view. 46 - /// 47 - /// `focal_time` is in the same unbounded t-space as eval_coil — one unit of t 48 - /// equals one turn of the outermost helix. It advances continuously as new 49 - /// events arrive (when auto_follow is true). 32 + /// 1 unit of focal_time = 1 outermost helix turn = 1 minute. 50 33 #[derive(Resource, Debug)] 51 34 pub struct HelixState { 52 - /// Focal time in unbounded t-space (1 unit = 1 outermost turn). 53 35 pub focal_time: f32, 54 - /// Currently active zoom level index into HelixHierarchy.levels. 55 36 pub active_level: usize, 56 - /// Smooth interpolation target for active_level (float for animation). 57 37 pub target_level: f32, 58 - /// Continuous interpolated level for smooth alpha blending in visualization. 59 38 pub interpolated_level: f32, 60 - /// Whether focal_time should auto-advance to track the latest data. 61 39 pub auto_follow: bool, 62 - /// Time tracking speed: 1.0 = real-time, 0.0 = frozen. 63 - /// When < 1.0, auto_follow lerps toward latest_t instead of snapping. 64 40 pub time_scale: f32, 65 41 } 66 42 67 - /// Create default helix hierarchy with sensible parameters. 68 - /// 69 - /// One unit of t = one turn of the outermost helix = one minute of real time 70 - /// (configured via TimeWindow::MICROS_PER_TURN). 71 43 pub fn default_hierarchy() -> HelixHierarchy { 72 44 HelixHierarchy { 73 45 levels: vec![ ··· 105 77 } 106 78 } 107 79 108 - /// Evaluate the helix frame at parameter t through all levels up to `level`. 109 - /// 110 - /// Uses iterative Gram-Schmidt orthogonalization (following the time-helix 111 - /// reference implementation) instead of a fixed up-vector cross product. 112 - /// This avoids frame discontinuities when the tangent is near-parallel to 113 - /// any fixed axis. 114 - /// 115 - /// t is unbounded — one unit = one turn of the outermost helix. 80 + /// Evaluate helix frame at t through all levels up to `level`. 81 + /// Uses iterative Gram-Schmidt to avoid frame discontinuities. 116 82 pub fn eval_coil(t: f32, level: usize, hierarchy: &HelixHierarchy) -> Frame { 117 83 use std::f32::consts::TAU; 118 84 ··· 121 87 let theta = t * TAU; 122 88 let (s, c) = theta.sin_cos(); 123 89 124 - // Level 0: base helix position and tangent 125 90 let mut px = r * c; 126 91 let mut py = t * p; 127 92 let mut pz = r * s; 128 93 129 - // Tangent = derivative of position w.r.t. t 130 94 let mut dtx = -r * TAU * s; 131 95 let mut dty = p; 132 96 let mut dtz = r * TAU * c; 133 97 134 - // Initial normal: radial direction (inward toward helix axis) 135 98 let mut nx = -c; 136 99 let mut ny = 0.0_f32; 137 100 let mut nz = -s; 138 101 139 - // Binormal from tangent × normal 140 102 let t_len = (dtx * dtx + dty * dty + dtz * dtz).sqrt(); 141 103 let (mut tx, mut ty, mut tz) = (dtx / t_len, dty / t_len, dtz / t_len); 142 104 let mut bx = ty * nz - tz * ny; 143 105 let mut by = tz * nx - tx * nz; 144 106 let mut bz = tx * ny - ty * nx; 145 107 146 - // Iterate through child levels 147 108 for lvl in 1..=level { 148 109 let level_info = &hierarchy.levels[lvl]; 149 110 ··· 156 117 .product(); 157 118 let child_r = hierarchy.base_radius * depth_scale; 158 119 159 - // Cumulative total turns at this level — product of all turns_per_parent up to here. 160 120 let total_turns: f32 = hierarchy.levels[..=lvl] 161 121 .iter() 162 122 .map(|l| l.turns_per_parent) ··· 165 125 let (sa, ca) = alpha.sin_cos(); 166 126 let w = total_turns * TAU; 167 127 168 - // Offset from parent along normal/binormal plane 169 128 let dx = nx * child_r * ca + bx * child_r * sa; 170 129 let dy = ny * child_r * ca + by * child_r * sa; 171 130 let dz = nz * child_r * ca + bz * child_r * sa; ··· 174 133 py += dy; 175 134 pz += dz; 176 135 177 - // Update tangent: add derivative of the child offset 178 - // d(offset)/dt = child_r * w * (-sin(alpha) * normal + cos(alpha) * binormal) 179 136 let ex = -nx * child_r * sa * w + bx * child_r * ca * w; 180 137 let ey = -ny * child_r * sa * w + by * child_r * ca * w; 181 138 let ez = -nz * child_r * sa * w + bz * child_r * ca * w; ··· 189 146 ty = dty / t_len; 190 147 tz = dtz / t_len; 191 148 192 - // Gram-Schmidt: orthogonalize offset direction against new tangent 193 - // to get the new normal (rotation-minimizing frame) 194 149 let dot = dx * tx + dy * ty + dz * tz; 195 150 let mut nnx = dx - dot * tx; 196 151 let mut nny = dy - dot * ty; ··· 204 159 ny = nny; 205 160 nz = nnz; 206 161 207 - // Recompute binormal from tangent × normal 208 162 bx = ty * nz - tz * ny; 209 163 by = tz * nx - tx * nz; 210 164 bz = tx * ny - ty * nx; ··· 218 172 } 219 173 } 220 174 221 - /// Compute the 3D position on the helix where the camera should focus. 222 - /// Always tracks level 0 (outermost helix) so the camera doesn't jump 223 - /// when the user zooms between levels. 224 175 pub fn compute_focal_point(state: &HelixState, hierarchy: &HelixHierarchy) -> Vec3 { 225 176 let frame = eval_coil(state.focal_time, 0, hierarchy); 226 177 frame.position 227 178 } 228 179 229 - /// Precomputed helix geometry buffer. Computed once at startup and extended 230 - /// lazily as time progresses. Each frame, `draw_helix` just slices the 231 - /// visible window from this buffer — zero eval_coil calls per frame. 232 180 #[derive(Resource)] 233 181 pub struct HelixGeometry { 234 - /// Per-level precomputed vertex buffers. 235 - /// Each entry: (t_start, t_step, positions). 236 182 pub levels: Vec<HelixLevelGeometry>, 237 - /// How far ahead (in t-space) we've precomputed. 238 183 pub precomputed_to: f32, 239 184 } 240 185 ··· 244 189 pub positions: Vec<Vec3>, 245 190 } 246 191 247 - /// Samples per unit of t for each level when precomputing. 248 - /// Innermost levels get more samples for smoother curves. 249 192 fn samples_per_t(level_idx: usize, hierarchy: &HelixHierarchy) -> f32 { 250 193 let total_turns: f32 = hierarchy.levels[..=level_idx] 251 194 .iter() 252 195 .map(|l| l.turns_per_parent) 253 196 .product(); 254 - // Each turn gets samples_per_turn points. Innermost level uses 64 for smoothness. 255 197 let spt = if level_idx == hierarchy.levels.len() - 1 { 256 198 64.0 257 199 } else { ··· 260 202 total_turns * spt 261 203 } 262 204 263 - /// How much t-space to precompute ahead of current time. Generous buffer 264 - /// so we don't need to extend frequently. 265 - const PRECOMPUTE_AHEAD: f32 = 30.0; // 30 minutes 266 - const PRECOMPUTE_BEHIND: f32 = 10.0; // 5 minutes behind 205 + const PRECOMPUTE_AHEAD: f32 = 30.0; 206 + const PRECOMPUTE_BEHIND: f32 = 10.0; 267 207 268 - /// Build the initial helix geometry buffer. 269 208 pub fn setup_helix_geometry(mut commands: Commands, hierarchy: Res<HelixHierarchy>) { 270 209 let t_start = -PRECOMPUTE_BEHIND; 271 210 let t_end = PRECOMPUTE_AHEAD; ··· 301 240 }); 302 241 } 303 242 304 - /// Extend the precomputed buffer if focal_time is approaching the end. 305 243 pub fn extend_helix_geometry( 306 244 state: Res<HelixState>, 307 245 hierarchy: Res<HelixHierarchy>, 308 246 mut geometry: ResMut<HelixGeometry>, 309 247 ) { 310 - // Extend when we're within 5 minutes of the precomputed boundary. 311 248 if state.focal_time + 5.0 < geometry.precomputed_to { 312 249 return; 313 250 } ··· 331 268 geometry.precomputed_to = new_end; 332 269 } 333 270 334 - /// Cached retained gizmo state. 335 271 #[derive(Resource)] 336 272 pub struct HelixGizmoCache { 337 273 pub last_focal_time: f32, ··· 351 287 } 352 288 } 353 289 354 - /// Draw helix using retained GizmoAsset from precomputed geometry slices. 355 - /// All levels are visible. Rebuilds asset only when the view changes. 356 290 pub fn draw_helix( 357 291 mut commands: Commands, 358 292 hierarchy: Res<HelixHierarchy>, ··· 403 337 } 404 338 } 405 339 406 - // Reuse handle — never leak assets 407 340 match &cache.asset_handle { 408 341 Some(h) => { 409 342 let _ = gizmo_assets.insert(h.id(), gizmo); ··· 429 362 mod tests { 430 363 use super::*; 431 364 432 - // ----------------------------------------------------------------------- 433 - // Helpers 434 - // ----------------------------------------------------------------------- 435 365 436 366 /// Assert all components of a Vec3 are finite (non-NaN and non-Inf). 437 367 fn assert_finite(v: Vec3, label: &str) { ··· 449 379 ); 450 380 } 451 381 452 - // ----------------------------------------------------------------------- 453 - // I1a: default_hierarchy() structure 454 - // ----------------------------------------------------------------------- 455 382 456 383 #[test] 457 384 fn default_hierarchy_has_three_levels_with_positive_params() { ··· 472 399 } 473 400 } 474 401 475 - // ----------------------------------------------------------------------- 476 - // I1b: eval_coil level-0 produces finite positions over a wide t range 477 - // ----------------------------------------------------------------------- 478 402 479 403 #[test] 480 404 fn eval_coil_level0_finite_for_extreme_t_values() { ··· 488 412 } 489 413 } 490 414 491 - // ----------------------------------------------------------------------- 492 - // I1c: Frame vectors are approximately orthonormal at level 0 493 - // ----------------------------------------------------------------------- 494 415 495 416 #[test] 496 417 fn eval_coil_level0_frame_is_orthonormal() { ··· 512 433 } 513 434 } 514 435 515 - // ----------------------------------------------------------------------- 516 - // I1d: eval_coil level-1 positions are offset from level-0 by ~child_radius 517 - // ----------------------------------------------------------------------- 518 436 519 437 #[test] 520 438 fn eval_coil_level1_offset_from_level0_by_child_radius() { ··· 538 456 } 539 457 } 540 458 541 - // ----------------------------------------------------------------------- 542 - // I1e: eval_coil level-1 produces finite positions 543 - // ----------------------------------------------------------------------- 544 459 545 460 #[test] 546 461 fn eval_coil_level1_finite_for_various_t_values() {
+50 -213
src/ingest.rs
··· 1 1 use bevy::prelude::*; 2 - use crossbeam_channel::Receiver; 2 + use crossbeam_channel::{Receiver, bounded}; 3 3 use crossbeam_queue::ArrayQueue; 4 4 use futures_util::StreamExt; 5 - use jacquard::jetstream::JetstreamMessage; 5 + use jacquard::DefaultStr; 6 + use jacquard::jetstream::{JetstreamMessage, JetstreamParams}; 6 7 use jacquard::types::string::{Did, Nsid}; 7 8 use jacquard::xrpc::{BasicSubscriptionClient, SubscriptionClient}; 8 9 use std::sync::Arc; 10 + use std::sync::atomic::{AtomicBool, AtomicI64}; 11 + use std::time::Duration; 9 12 10 13 use crate::db::{DbChannel, DbEvent, DbReadRequest, DbWriteRequest}; 11 14 use crate::helix::{self, HelixHierarchy, HelixState}; 12 15 use crate::net::AsyncTungsteniteClient; 13 16 14 - // --------------------------------------------------------------------------- 15 - // Jetstream → Bevy event pipeline 16 - // --------------------------------------------------------------------------- 17 + use jacquard::api::app_bsky::feed::like::LikeRecord; 18 + use jacquard::api::app_bsky::feed::post::PostRecord; 19 + use jacquard::api::app_bsky::graph::follow::FollowRecord; 20 + use jacquard::common::types::collection::Collection; 17 21 18 - /// Type alias for events flowing from Jetstream → Bevy. 19 22 pub type IngestEvent = JetstreamMessage; 20 23 21 - /// Lock-free bounded queue for Jetstream → Bevy communication. 22 - /// 23 - /// Uses crossbeam's `ArrayQueue` with `force_push()` — when full, the oldest 24 - /// event is displaced. Truly lock-free (no mutex), both push and pop take `&self`. 25 - /// The producer runs at firehose speed on IoTaskPool; the consumer pops per frame. 24 + /// Lock-free ring buffer: producer force_push, consumer pops per frame. 26 25 #[derive(Resource)] 27 26 pub struct JetstreamChannels { 28 27 pub queue: Arc<ArrayQueue<IngestEvent>>, 29 28 } 30 29 31 - /// Shared mesh + material handles for the small dot rendered at each event position. 32 - /// Spawned once at startup; every event entity clones these handles (cheap, instanced). 33 30 #[derive(Resource)] 34 31 pub struct EventDotAssets { 35 32 pub mesh: Handle<Mesh>, ··· 40 37 pub default_bsky: Handle<StandardMaterial>, 41 38 } 42 39 43 - /// Shared time state written by the consumer task, read by Bevy systems. 44 - /// Updated for EVERY event (not just sampled ones) so time tracking is accurate. 45 40 #[derive(Resource)] 46 41 pub struct SharedTimeState { 47 - pub anchor_us: Arc<std::sync::atomic::AtomicI64>, 48 - pub latest_us: Arc<std::sync::atomic::AtomicI64>, 49 - pub initialized: Arc<std::sync::atomic::AtomicBool>, 42 + pub anchor_us: Arc<AtomicI64>, 43 + pub latest_us: Arc<AtomicI64>, 44 + pub initialized: Arc<AtomicBool>, 50 45 } 51 46 52 - // --------------------------------------------------------------------------- 53 - // Task 5: Event Marker Component and Time Window Resource 54 - // --------------------------------------------------------------------------- 55 - 56 - /// Component wrapping a Jetstream event. Preserves the full JetstreamMessage enum. 57 - /// Commit events carry Did, CommitOperation, Nsid, Rkey, Data, Cid. 58 - /// Identity events carry Did, Handle, seq, time. 59 - /// Account events carry Did, active, status, seq, time. 60 - /// All types are owned on bos-beta — no lifetime parameters. 61 47 #[derive(Component, Debug, Clone)] 62 48 pub struct JetstreamEventMarker(pub JetstreamMessage); 63 49 64 50 impl JetstreamEventMarker { 65 - /// Extract the DID from any event variant. 66 51 pub fn did(&self) -> &Did { 67 52 match &self.0 { 68 53 JetstreamMessage::Commit { did, .. } => did, ··· 71 56 } 72 57 } 73 58 74 - /// Extract time_us from any event variant. 75 59 pub fn time_us(&self) -> i64 { 76 60 match &self.0 { 77 61 JetstreamMessage::Commit { time_us, .. } => *time_us, ··· 80 64 } 81 65 } 82 66 83 - /// Extract collection NSID (only for Commit events). 84 67 pub fn collection(&self) -> Option<&Nsid> { 85 68 match &self.0 { 86 69 JetstreamMessage::Commit { commit, .. } => Some(&commit.collection), ··· 88 71 } 89 72 } 90 73 91 - /// Extract the record Data (only for Commit events that carry a record). 92 74 pub fn record(&self) -> Option<&jacquard::Data> { 93 75 match &self.0 { 94 76 JetstreamMessage::Commit { commit, .. } => commit.record.as_ref(), ··· 97 79 } 98 80 } 99 81 100 - /// Marker: this event entity needs a particle effect spawned. 101 - /// 102 - /// Attached to every newly spawned event entity by `drain_jetstream_events`. 103 - /// Consumed and removed by `spawn_event_particles` after the effect child 104 - /// is attached. 105 82 #[derive(Component)] 106 83 pub struct NeedsParticle; 107 84 108 - /// Whether this event came from the live Jetstream feed or historical SQLite load. 109 - /// 110 - /// Determines which effect variant to use: 111 - /// - `Live` → bloom outward (particles radiate away from helix surface) 112 - /// - `Historical` → bloom inward (particles converge onto the helix surface) 85 + /// Live = bloom outward, Historical = bloom inward. 113 86 #[derive(Component, Debug, Clone, Copy, PartialEq)] 114 87 pub enum EventSource { 115 88 Live, 116 89 Historical, 117 90 } 118 91 119 - /// Maps absolute timestamps (microseconds) to unbounded helix t-space. 120 - /// 121 - /// One unit of t = one turn of the outermost helix = MICROS_PER_TURN microseconds. 122 - /// `anchor_us` is set on first event. `t = (time_us - anchor_us) / MICROS_PER_TURN`. 123 - /// t is unbounded and grows continuously as time passes. 92 + /// `t = (time_us - anchor_us) / MICROS_PER_TURN`. 1 t-unit = 1 minute. 124 93 #[derive(Resource, Debug)] 125 94 pub struct TimeWindow { 126 95 pub anchor_us: i64, ··· 129 98 } 130 99 131 100 impl TimeWindow { 132 - /// Microseconds per one unit of t (one outermost helix turn). 133 - /// 60 seconds = one turn = one minute of real time. 134 101 pub const MICROS_PER_TURN: f64 = 60_000_000.0; 135 102 136 103 pub fn time_to_t(&self, time_us: i64) -> f32 { ··· 138 105 } 139 106 } 140 107 141 - // --------------------------------------------------------------------------- 142 - // Task 4 & 5: Historical event loading resources 143 - // --------------------------------------------------------------------------- 144 - 145 - /// Tracks which microsecond time ranges have already been loaded from SQLite. 146 - /// 147 - /// Stored as sorted, non-overlapping `(start_us, end_us)` intervals. Before 148 - /// triggering a new history query, callers check whether the desired range is 149 - /// already covered. 108 + /// Sorted, non-overlapping `(start_us, end_us)` intervals already loaded from SQLite. 150 109 #[derive(Resource, Debug, Default)] 151 110 pub struct LoadedRanges { 152 - /// Sorted list of `(start_us, end_us)` intervals that have been loaded. 153 111 pub ranges: Vec<(i64, i64)>, 154 112 } 155 113 156 114 impl LoadedRanges { 157 - /// Returns `true` if the given range is fully covered by loaded intervals. 158 115 pub fn is_covered(&self, start_us: i64, end_us: i64) -> bool { 159 116 self.ranges 160 117 .iter() 161 118 .any(|(lo, hi)| *lo <= start_us && *hi >= end_us) 162 119 } 163 120 164 - /// Add a new range, merging with any overlapping/adjacent intervals. 165 121 pub fn insert(&mut self, start_us: i64, end_us: i64) { 166 122 self.ranges.push((start_us, end_us)); 167 123 self.ranges.sort_unstable_by_key(|r| r.0); 168 124 169 - // Merge overlapping / adjacent intervals. 170 125 let mut merged: Vec<(i64, i64)> = Vec::with_capacity(self.ranges.len()); 171 126 for (lo, hi) in self.ranges.drain(..) { 172 127 if let Some(last) = merged.last_mut() { 173 - // Overlap or adjacent (within 1 µs) → extend. 174 128 if lo <= last.1 + 1 { 175 129 last.1 = last.1.max(hi); 176 130 continue; ··· 182 136 } 183 137 } 184 138 185 - /// State for in-flight history queries triggered by panning. 186 - /// 187 - /// Holds the response receiver for the most recently sent `QueryTimeRange` 188 - /// request so that `poll_history_responses` can drain it each frame. Also 189 - /// records the queried range so it can be marked as loaded on receipt. 190 139 #[derive(Resource, Default)] 191 140 pub struct HistoryQueryState { 192 - /// Response channel for the current in-flight query, if any. 193 141 pub pending: Option<Receiver<Vec<Vec<rusqlite::types::Value>>>>, 194 - /// The `(start_us, end_us)` range of the pending query (used to update 195 - /// `LoadedRanges` once the response arrives). 196 142 pub pending_range: Option<(i64, i64)>, 197 143 } 198 144 199 - /// Sampling rate for high-volume event types (posts, likes). 200 - /// Only 1-in-N events are forwarded to the Bevy ring buffer. 201 - /// Everything goes to SQLite regardless. 202 145 const SAMPLE_RATE: u64 = 10; 203 - 204 - /// Ring buffer capacity. When full, oldest events are silently overwritten 205 - /// by the producer — no backpressure, no blocking, no cascading drops. 206 146 const RING_BUFFER_CAPACITY: usize = 1024; 207 147 208 - /// Spawn the Jetstream consumer task on IoTaskPool. 209 - /// 210 - /// The consumer: 211 - /// 1. Queries SQLite for the latest cached cursor 212 - /// 2. Connects to Jetstream and subscribes 213 - /// 3. For EVERY event: updates time tracking atomics + batches to SQLite 214 - /// 4. For SAMPLED events only: writes to the ring buffer for Bevy to consume 215 - /// 5. Reconnects on error with cursor 216 - /// 217 - fn emissive_mat(materials: &mut Assets<StandardMaterial>, base: Color, emissive: LinearRgba) -> Handle<StandardMaterial> { 148 + fn emissive_mat( 149 + materials: &mut Assets<StandardMaterial>, 150 + base: Color, 151 + emissive: LinearRgba, 152 + ) -> Handle<StandardMaterial> { 218 153 materials.add(StandardMaterial { 219 154 base_color: base, 220 155 emissive, ··· 222 157 }) 223 158 } 224 159 225 - /// Create the shared mesh + materials for event dot markers. 226 - /// Emissive materials give dots a self-illuminated glow against the black background. 227 160 pub fn setup_event_dots( 228 161 mut commands: Commands, 229 162 mut meshes: ResMut<Assets<Mesh>>, ··· 232 165 let mesh = meshes.add(Sphere::new(0.002).mesh().ico(2).unwrap()); 233 166 commands.insert_resource(EventDotAssets { 234 167 mesh, 235 - post: emissive_mat(&mut materials, 168 + post: emissive_mat( 169 + &mut materials, 236 170 Color::srgb(0.08, 0.15, 0.4), 237 - LinearRgba::new(0.2, 0.4, 1.5, 1.0)), 238 - like: emissive_mat(&mut materials, 171 + LinearRgba::new(0.2, 0.4, 1.5, 1.0), 172 + ), 173 + like: emissive_mat( 174 + &mut materials, 239 175 Color::srgb(0.4, 0.08, 0.25), 240 - LinearRgba::new(1.5, 0.2, 0.8, 1.0)), 241 - follow: emissive_mat(&mut materials, 176 + LinearRgba::new(1.5, 0.2, 0.8, 1.0), 177 + ), 178 + follow: emissive_mat( 179 + &mut materials, 242 180 Color::srgb(0.08, 0.4, 0.2), 243 - LinearRgba::new(0.2, 1.5, 0.4, 1.0)), 244 - shiny: emissive_mat(&mut materials, 181 + LinearRgba::new(0.2, 1.5, 0.4, 1.0), 182 + ), 183 + shiny: emissive_mat( 184 + &mut materials, 245 185 Color::srgb(0.4, 0.35, 0.08), 246 - LinearRgba::new(1.5, 1.2, 0.2, 1.0)), 247 - default_bsky: emissive_mat(&mut materials, 186 + LinearRgba::new(1.5, 1.2, 0.2, 1.0), 187 + ), 188 + default_bsky: emissive_mat( 189 + &mut materials, 248 190 Color::srgb(0.15, 0.15, 0.2), 249 - LinearRgba::new(0.4, 0.4, 0.5, 1.0)), 191 + LinearRgba::new(0.4, 0.4, 0.5, 1.0), 192 + ), 250 193 }); 251 194 } 252 195 253 - /// Time tracking uses atomics so the main thread always has accurate time 254 - /// state even when it can't keep up with the full event rate. 255 196 pub fn spawn_jetstream_consumer(mut commands: Commands, db_channel: Res<DbChannel>) { 256 197 use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; 257 198 ··· 283 224 284 225 // Query latest cursor from SQLite for reconnection. 285 226 let initial_cursor = { 286 - let (resp_tx, resp_rx) = crossbeam_channel::bounded::<Option<i64>>(1); 227 + let (resp_tx, resp_rx) = bounded::<Option<i64>>(1); 287 228 if db_reader 288 229 .send(DbReadRequest::GetLatestCursor { 289 230 response_tx: resp_tx, ··· 317 258 318 259 let sub_client = BasicSubscriptionClient::new(client, base_uri); 319 260 320 - let params = jacquard::jetstream::JetstreamParams::<jacquard::DefaultStr>::new() 261 + let params = JetstreamParams::<DefaultStr>::new() 321 262 .wanted_collections(vec![]) 322 263 .compress(false) 323 264 .build(); ··· 349 290 }; 350 291 cursor = Some(time_us); 351 292 352 - // Update shared time state atomically — always, for every event. 353 293 if !init_handle.load(Ordering::Relaxed) { 354 294 anchor_handle.store(time_us, Ordering::Relaxed); 355 295 init_handle.store(true, Ordering::Release); 356 296 } 357 297 latest_handle.fetch_max(time_us, Ordering::Relaxed); 358 298 359 - // SQLite: batch ALL events. 360 299 if let Some(db_event) = jetstream_to_db_event(&msg) { 361 300 db_batch.push(db_event); 362 301 } 363 302 364 - // Ring buffer: sample high-volume events. 365 303 let is_high_volume = match &msg { 366 304 JetstreamMessage::Commit { commit, .. } => { 367 - use jacquard::api::app_bsky::feed::like::LikeRecord; 368 - use jacquard::api::app_bsky::feed::post::PostRecord; 369 - use jacquard::common::types::collection::Collection; 370 305 let col: &str = commit.collection.as_ref(); 371 306 col == <PostRecord as Collection>::NSID 372 307 || col == <LikeRecord as Collection>::NSID ··· 385 320 queue_handle.force_push(msg); 386 321 } 387 322 388 - // Flush DB batch on size or time threshold. 389 323 if db_batch.len() >= 100 390 - || last_flush.elapsed() 391 - >= std::time::Duration::from_millis(500) 324 + || last_flush.elapsed() >= Duration::from_millis(500) 392 325 { 393 326 flush_db_batch(&db_writer, &mut db_batch); 394 327 last_flush = std::time::Instant::now(); ··· 407 340 } 408 341 } 409 342 410 - futures_timer::Delay::new(std::time::Duration::from_secs(2)).await; 343 + futures_timer::Delay::new(Duration::from_secs(2)).await; 411 344 } 412 345 }) 413 346 .detach(); 414 347 } 415 348 416 - // --------------------------------------------------------------------------- 417 - // SQLite batch helpers 418 - // --------------------------------------------------------------------------- 419 - 420 - /// Flush the accumulated batch to the SQLite worker and clear the buffer. 421 - /// 422 - /// If the channel is full or closed, events are dropped with a warning rather 423 - /// than blocking the async consumer task. 424 349 fn flush_db_batch(db_writer: &crossbeam_channel::Sender<DbWriteRequest>, batch: &mut Vec<DbEvent>) { 425 350 if batch.is_empty() { 426 351 return; ··· 434 359 } 435 360 } 436 361 437 - /// Convert a `JetstreamMessage` into a `DbEvent` for SQLite persistence. 438 - /// 439 - /// The entire message is serialized as JSON into `message_json` for a lossless 440 - /// round-trip. Indexed columns (`kind`, `did`, `time_us`, `collection`) are 441 - /// extracted separately so SQLite can filter efficiently without parsing JSON. 442 362 fn jetstream_to_db_event(msg: &JetstreamMessage) -> Option<DbEvent> { 443 363 let message_json = match serde_json::to_string(msg) { 444 364 Ok(json) => json, ··· 476 396 }) 477 397 } 478 398 479 - /// Drain pre-filtered events from the ring buffer and spawn ECS entities. 480 - /// 481 - /// The producer task (IoTaskPool) already sampled high-volume events and 482 - /// updated SharedTimeState atomics. This system just pops whatever's 483 - /// available and spawns entities — no filtering, no time bookkeeping. 484 - /// If a frame hitches, the ring buffer overwrites old events silently. 485 399 pub fn drain_jetstream_events( 486 400 mut commands: Commands, 487 401 channels: Res<JetstreamChannels>, ··· 534 448 535 449 let dot_mat = match &event { 536 450 JetstreamMessage::Commit { commit, .. } => { 537 - use jacquard::api::app_bsky::feed::like::LikeRecord; 538 - use jacquard::api::app_bsky::feed::post::PostRecord; 539 - use jacquard::api::app_bsky::graph::follow::FollowRecord; 540 - use jacquard::common::types::collection::Collection; 541 451 let col: &str = commit.collection.as_ref(); 542 452 if col == <PostRecord as Collection>::NSID { 543 453 dot_assets.post.clone() ··· 567 477 } 568 478 } 569 479 570 - // --------------------------------------------------------------------------- 571 - // Task 4: Load historical events on startup 572 - // --------------------------------------------------------------------------- 573 - 574 480 /// Startup system: query SQLite for recent events and spawn them as historical entities. 575 - /// 576 - /// This runs after `spawn_db_thread` and `spawn_jetstream_consumer`. It uses a 577 - /// blocking `recv()` which is acceptable at startup (the DB worker responds 578 - /// quickly to the MAX query). Events are placed on the helix with 579 - /// `EventSource::Historical` so the particle system shows reverse-bloom effects. 580 - /// 581 - /// The query window: up to 1 hour before the most recent persisted event. 582 481 pub fn load_historical_events( 583 482 mut commands: Commands, 584 483 db_channel: Res<DbChannel>, ··· 587 486 mut time_window: ResMut<TimeWindow>, 588 487 mut loaded_ranges: ResMut<LoadedRanges>, 589 488 ) { 590 - // Step 1: get the latest cursor so we know where in time we are. 591 489 let latest_us = { 592 - let (resp_tx, resp_rx) = crossbeam_channel::bounded::<Option<i64>>(1); 490 + let (resp_tx, resp_rx) = bounded::<Option<i64>>(1); 593 491 if db_channel 594 492 .reader 595 493 .send(DbReadRequest::GetLatestCursor { ··· 614 512 return; 615 513 }; 616 514 617 - // Step 2: compute the query window: last 1 hour = 3600 seconds. 618 - const HISTORY_WINDOW_US: i64 = 60_000_000; // 1 minute in microseconds 515 + const HISTORY_WINDOW_US: i64 = 60_000_000; 619 516 let start_us = end_us - HISTORY_WINDOW_US; 620 517 621 518 info!( ··· 623 520 start_us, end_us 624 521 ); 625 522 626 - // Step 3: query the time range. 627 - let (resp_tx, resp_rx) = crossbeam_channel::bounded::<Vec<Vec<rusqlite::types::Value>>>(1); 523 + let (resp_tx, resp_rx) = bounded::<Vec<Vec<rusqlite::types::Value>>>(1); 628 524 if db_channel 629 525 .reader 630 526 .send(DbReadRequest::QueryTimeRange { ··· 651 547 rows.len() 652 548 ); 653 549 654 - // Step 4: initialize TimeWindow from historical data if not yet set. 655 550 if !time_window.is_initialized 656 551 && let Some(first_row) = rows.first() 657 552 && let Some(t) = row_time_us(first_row) ··· 664 559 ); 665 560 } 666 561 667 - // Step 5: spawn entities for all historical rows. 668 562 let count = 669 563 spawn_historical_entities(&mut commands, &rows, &hierarchy, &state, &mut time_window); 670 564 ··· 673 567 count 674 568 ); 675 569 676 - // Step 6: record this range as loaded so pan detection doesn't re-request it. 677 570 loaded_ranges.insert(start_us, end_us); 678 571 } 679 572 680 - // --------------------------------------------------------------------------- 681 - // Task 5: Poll for pan-triggered history query responses 682 - // --------------------------------------------------------------------------- 683 - 684 - /// Update system: drain any pending history query responses and spawn entities. 685 - /// 686 - /// Each frame, checks if a pan-triggered `QueryTimeRange` has returned results. 687 - /// Spawns all historical entities from the response and marks the range as loaded. 688 573 pub fn poll_history_responses( 689 574 mut commands: Commands, 690 575 mut query_state: ResMut<HistoryQueryState>, ··· 711 596 count 712 597 ); 713 598 714 - // Mark the queried range as loaded so we don't re-request it. 715 599 if let Some((start_us, end_us)) = query_state.pending_range.take() { 716 600 loaded_ranges.insert(start_us, end_us); 717 601 } 718 602 719 - // Clear the pending receiver — the query is complete. 720 603 query_state.pending = None; 721 604 } 722 - Err(crossbeam_channel::TryRecvError::Empty) => { 723 - // Still waiting; nothing to do this frame. 724 - } 605 + Err(crossbeam_channel::TryRecvError::Empty) => {} 606 + 725 607 Err(crossbeam_channel::TryRecvError::Disconnected) => { 726 608 warn!("poll_history_responses: SQLite worker closed response channel"); 727 609 query_state.pending = None; ··· 730 612 } 731 613 } 732 614 733 - // --------------------------------------------------------------------------- 734 - // Shared helpers 735 - // --------------------------------------------------------------------------- 736 - 737 - /// Extract `time_us` from a DB row. 738 - /// 739 - /// The `time_us` column is index 3 (0-indexed: id=0, kind=1, did=2, time_us=3). 740 615 pub fn row_time_us(row: &[rusqlite::types::Value]) -> Option<i64> { 741 616 match row.get(3)? { 742 617 rusqlite::types::Value::Integer(v) => Some(*v), ··· 744 619 } 745 620 } 746 621 747 - /// Extract a text value from a DB row at the given column index. 748 622 fn row_text(row: &[rusqlite::types::Value], idx: usize) -> Option<&str> { 749 623 match row.get(idx)? { 750 624 rusqlite::types::Value::Text(s) => Some(s.as_str()), ··· 752 626 } 753 627 } 754 628 755 - /// Convert a DB row into a `JetstreamMessage`. 756 - /// 757 - /// Columns: id(0), kind(1), did(2), time_us(3), collection(4), message_json(5). 758 - /// 759 - /// Deserializes the full `JetstreamMessage` from `message_json` — lossless 760 - /// round-trip with no fabricated placeholder values. 761 629 fn row_to_message(row: &[rusqlite::types::Value]) -> Option<JetstreamMessage> { 762 630 let json = row_text(row, 5)?; 763 631 match serde_json::from_str::<JetstreamMessage>(json) { ··· 769 637 } 770 638 } 771 639 772 - /// Spawn ECS entities for a batch of DB rows. 773 - /// 774 - /// Returns the number of entities successfully spawned. Rows that cannot be 775 - /// converted (missing required fields, unknown kind, etc.) are skipped with 776 - /// a warning rather than aborting the batch. 777 - /// 778 - /// Each entity receives: 779 - /// - `JetstreamEventMarker` with the reconstructed message 780 - /// - `Transform` at the helix position for the event's `time_us` 781 - /// - `Visibility::default()` (required for particle hierarchy) 782 - /// - NO `NeedsParticle` — historical events skip particles on load (perf) 783 - /// - `EventSource::Historical` 784 640 fn spawn_historical_entities( 785 641 commands: &mut Commands, 786 642 rows: &[Vec<rusqlite::types::Value>], ··· 800 656 continue; 801 657 }; 802 658 803 - // Lazily initialize the TimeWindow if this is the first event we process. 804 659 if !time_window.is_initialized { 805 660 time_window.anchor_us = time_us; 806 661 time_window.is_initialized = true; ··· 811 666 812 667 let frame = helix::eval_coil(t, hierarchy.levels.len() - 1, hierarchy); 813 668 814 - // Historical events skip NeedsParticle — no point blooming particles 815 - // for events already in the past. Particles can be added later if the 816 - // user pans back to inspect this region. 817 669 commands.spawn(( 818 670 JetstreamEventMarker(msg), 819 671 Transform::from_translation(frame.position), ··· 827 679 count 828 680 } 829 681 830 - // --------------------------------------------------------------------------- 831 - // Tests 832 - // --------------------------------------------------------------------------- 833 - 834 682 #[cfg(test)] 835 683 mod tests { 836 684 use super::*; ··· 840 688 use jacquard::types::string::{Cid, Datetime, Did, Nsid, Rkey}; 841 689 use std::str::FromStr; 842 690 843 - // ----------------------------------------------------------------------- 844 - // LoadedRanges::is_covered 845 - // ----------------------------------------------------------------------- 846 691 847 692 #[test] 848 693 fn is_covered_empty_ranges_returns_false() { ··· 861 706 fn is_covered_subset_of_loaded_range() { 862 707 let mut ranges = LoadedRanges::default(); 863 708 ranges.insert(0, 1000); 864 - // Any sub-range that fits inside should be covered. 865 709 assert!(ranges.is_covered(100, 200)); 866 710 assert!(ranges.is_covered(0, 1000)); 867 711 assert!(ranges.is_covered(500, 999)); ··· 871 715 fn is_covered_partial_overlap_returns_false() { 872 716 let mut ranges = LoadedRanges::default(); 873 717 ranges.insert(100, 200); 874 - // Extends beyond the loaded range on either side. 875 718 assert!(!ranges.is_covered(50, 200)); 876 719 assert!(!ranges.is_covered(100, 250)); 877 720 assert!(!ranges.is_covered(50, 250)); ··· 885 728 assert!(!ranges.is_covered(0, 50)); 886 729 } 887 730 888 - // ----------------------------------------------------------------------- 889 - // LoadedRanges::insert 890 - // ----------------------------------------------------------------------- 891 731 892 732 #[test] 893 733 fn insert_single_range() { ··· 942 782 assert_eq!(ranges.ranges, vec![(100, 600)]); 943 783 } 944 784 945 - // ----------------------------------------------------------------------- 946 - // Serialization roundtrip: JetstreamMessage 947 - // ----------------------------------------------------------------------- 948 785 949 786 fn make_commit_message() -> JetstreamMessage { 950 787 let did = Did::new_static("did:plc:testuser123456789").expect("valid DID");
+1 -5
src/interaction.rs
··· 198 198 } 199 199 } 200 200 201 - /// Pan past loaded range → trigger SQLite query (5 min either side of focal_time). 201 + /// Pan past loaded range -> trigger SQLite query (5 min either side of focal_time). 202 202 pub fn check_pan_triggers_history_load( 203 203 state: Res<HelixState>, 204 204 time_window: Res<TimeWindow>, ··· 251 251 query_state.pending = Some(resp_rx); 252 252 query_state.pending_range = Some((query_start, query_end)); 253 253 } 254 - 255 - // --------------------------------------------------------------------------- 256 - // Event picking 257 - // --------------------------------------------------------------------------- 258 254 259 255 #[derive(Resource, Default)] 260 256 pub struct SelectedEvent {
+3
src/main.rs
··· 50 50 .init_resource::<ingest::LoadedRanges>() 51 51 .init_resource::<ingest::HistoryQueryState>() 52 52 .init_resource::<particles::CleanupTimer>() 53 + .init_asset::<slides::SlideDeckAsset>() 54 + .init_asset_loader::<slides::SlideDeckLoader>() 53 55 .init_resource::<slides::SlideDeck>() 54 56 .init_resource::<slides::SlideMode>() 55 57 .insert_resource(ClearColor(Color::BLACK)) ··· 84 86 interaction::check_pan_triggers_history_load.after(interaction::drag_pan_time), 85 87 interaction::toggle_pause, 86 88 interaction::toggle_slow_time, 89 + slides::sync_slide_deck, 87 90 slides::slide_navigation, 88 91 interaction::auto_follow_latest, 89 92 interaction::interpolate_zoom_level,
+19 -33
src/net.rs
··· 1 - use async_tungstenite::async_tls::client_async_tls; 2 - use async_tungstenite::tungstenite; 1 + use std::sync::Arc; 2 + 3 + use async_tungstenite::tungstenite::protocol::frame; 4 + use async_tungstenite::tungstenite::{self, Utf8Bytes}; 5 + use async_tungstenite::{async_tls::client_async_tls, tungstenite::Message}; 3 6 use bytes::Bytes; 4 7 use futures_util::{SinkExt, StreamExt}; 5 8 use jacquard::{ 6 9 CloseCode, CloseFrame, StreamError, WebSocketClient, WebSocketConnection, WsMessage, WsSink, 7 - WsStream, WsText, 10 + WsStream, WsText, client::BasicClient, 8 11 }; 9 12 use smol::net::TcpStream; 10 - 11 - // --------------------------------------------------------------------------- 12 - // Task 2: WebSocketClient using async-tungstenite + async-tls (rustls) 13 - // --------------------------------------------------------------------------- 14 13 15 14 #[derive(Debug, Clone, Default)] 16 15 pub struct AsyncTungsteniteClient; ··· 64 63 } 65 64 } 66 65 67 - fn convert_to_ws_message(msg: tungstenite::Message) -> Option<WsMessage> { 66 + fn convert_to_ws_message(msg: Message) -> Option<WsMessage> { 68 67 match msg { 69 - tungstenite::Message::Text(utf8_bytes) => { 68 + Message::Text(utf8_bytes) => { 70 69 // Both Utf8Bytes and WsText wrap Bytes with UTF-8 invariant — zero-copy 71 70 let bytes: Bytes = utf8_bytes.into(); 72 71 // Safety: tungstenite already validated UTF-8 ··· 74 73 WsText::from_bytes_unchecked(bytes) 75 74 })) 76 75 } 77 - tungstenite::Message::Binary(data) => Some(WsMessage::Binary(data)), 78 - tungstenite::Message::Close(frame) => { 76 + Message::Binary(data) => Some(WsMessage::Binary(data)), 77 + Message::Close(frame) => { 79 78 let close_frame = frame.map(|f| { 80 79 let code = convert_close_code(f.code); 81 80 let reason_bytes: Bytes = f.reason.into(); ··· 89 88 } 90 89 } 91 90 92 - fn convert_from_ws_message(msg: WsMessage) -> tungstenite::Message { 93 - use tungstenite::protocol::frame::Utf8Bytes; 91 + fn convert_from_ws_message(msg: WsMessage) -> Message { 94 92 match msg { 95 93 WsMessage::Text(text) => { 96 94 // WsText → Bytes → Utf8Bytes, both already UTF-8 validated 97 95 let bytes = text.into_bytes(); 98 96 // Safety: WsText guarantees UTF-8 99 - tungstenite::Message::Text(unsafe { Utf8Bytes::from_bytes_unchecked(bytes) }) 97 + Message::Text(unsafe { Utf8Bytes::from_bytes_unchecked(bytes) }) 100 98 } 101 - WsMessage::Binary(bytes) => tungstenite::Message::Binary(bytes), 99 + WsMessage::Binary(bytes) => Message::Binary(bytes), 102 100 WsMessage::Close(frame) => { 103 101 let close_frame = frame.map(|f| { 104 102 let code: u16 = f.code.into(); 105 - tungstenite::protocol::frame::CloseFrame { 103 + frame::CloseFrame { 106 104 code: code.into(), 107 105 reason: Utf8Bytes::from(f.reason.to_string()), 108 106 } 109 107 }); 110 - tungstenite::Message::Close(close_frame) 108 + Message::Close(close_frame) 111 109 } 112 110 } 113 111 } 114 112 115 - fn convert_close_code(code: tungstenite::protocol::frame::coding::CloseCode) -> CloseCode { 116 - use tungstenite::protocol::frame::coding::CloseCode as TC; 113 + fn convert_close_code(code: frame::coding::CloseCode) -> CloseCode { 114 + use frame::coding::CloseCode as TC; 117 115 match code { 118 116 TC::Normal => CloseCode::Normal, 119 117 TC::Away => CloseCode::Away, ··· 132 130 } 133 131 } 134 132 135 - // --------------------------------------------------------------------------- 136 - // Task 3: reqwest::Client as a shared Bevy resource 137 - // 138 - // With bevy-tokio-tasks, we have a real tokio runtime. reqwest::Client runs 139 - // natively on tokio — no DNS issues, no runtime conflicts. jacquard provides 140 - // `impl HttpClient for reqwest::Client` via the `reqwest-client` feature. 141 - // --------------------------------------------------------------------------- 142 - 143 133 use bevy::prelude::*; 144 134 145 135 /// Shared AT Protocol client for XRPC calls. 146 - /// Uses jacquard's BasicClient — unauthenticated, reqwest-backed. 147 - /// Wrapped in Arc for sharing with tokio background tasks. 148 136 #[derive(Resource, Clone)] 149 - pub struct AtpClient(pub std::sync::Arc<jacquard::client::BasicClient>); 137 + pub struct AtpClient(pub Arc<BasicClient>); 150 138 151 139 pub fn setup_atp_client(mut commands: Commands) { 152 - commands.insert_resource(AtpClient(std::sync::Arc::new( 153 - jacquard::client::BasicClient::unauthenticated(), 154 - ))); 140 + commands.insert_resource(AtpClient(Arc::new(BasicClient::unauthenticated()))); 155 141 }
+25 -193
src/particles.rs
··· 1 1 use bevy::prelude::*; 2 2 use bevy::image::{Image, TextureFormatPixelInfo}; 3 3 use bevy_hanabi::prelude::*; 4 - // Use the hanabi Gradient explicitly to avoid ambiguity with bevy's UI Gradient 5 4 use bevy_hanabi::Gradient as HanabiGradient; 6 5 use jacquard::common::types::collection::Collection; 7 6 8 7 use crate::helix::HelixState; 9 8 use crate::ingest::{EventSource, JetstreamEventMarker, NeedsParticle, TimeWindow}; 10 9 11 - /// Which zoom variant to use when selecting a particle effect. 12 10 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 13 11 pub enum ZoomVariant { 14 - /// Helix levels 0–1 (hours, minutes): shorter lifetime, smaller size. 15 12 Outer, 16 - /// Helix levels 2–3 (seconds, sub-second): longer lifetime, larger size. 17 13 Inner, 18 14 } 19 15 20 16 impl ZoomVariant { 21 - /// Classify the current helix active level into a zoom variant. 22 17 pub fn from_level(level: usize) -> Self { 23 - if level <= 1 { 24 - ZoomVariant::Outer 25 - } else { 26 - ZoomVariant::Inner 27 - } 18 + if level <= 1 { ZoomVariant::Outer } else { ZoomVariant::Inner } 28 19 } 29 20 } 30 21 31 - /// Per-zoom-variant live/historical effect handle pair. 32 22 #[derive(Clone)] 33 23 pub struct ZoomEffects { 34 24 pub live: Handle<EffectAsset>, 35 25 pub hist: Handle<EffectAsset>, 36 26 } 37 27 38 - /// Outer (dense, short-lived) and inner (spread, long-lived) zoom variants for fallback effects. 39 28 #[derive(Clone)] 40 29 pub struct ZoomPair { 41 30 pub outer: ZoomEffects, ··· 43 32 } 44 33 45 34 impl ZoomPair { 46 - /// Select the appropriate ZoomEffects variant for the given zoom level. 47 35 pub fn select(&self, zoom: ZoomVariant) -> &ZoomEffects { 48 36 match zoom { 49 37 ZoomVariant::Outer => &self.outer, ··· 52 40 } 53 41 } 54 42 55 - /// Particle effects keyed by NSID or NSID prefix. Longest prefix match wins. 43 + /// NSID-keyed particle effects. Longest prefix match wins. 56 44 #[derive(Resource)] 57 45 pub struct ParticleEffects { 58 - /// Registered effects: (nsid_or_prefix, outer_variant, inner_variant). 59 - /// Sorted by key length descending for longest-prefix matching. 60 46 pub effects: Vec<(&'static str, ZoomEffects, ZoomEffects)>, 61 - /// Fallback for unmatched app.bsky.* collections (other Bluesky event types). 62 - /// Contains both outer and inner zoom variants. 63 47 pub bsky_default: ZoomPair, 64 - /// Distinctive "shiny" effect for non-Bluesky AT Protocol events 65 - /// (Identity/Account events, etc. with no collection NSID). 66 48 pub shiny: ZoomPair, 67 49 } 68 50 69 51 impl ParticleEffects { 70 - /// Look up the effect pair for the given NSID and zoom variant. 71 52 pub fn lookup(&self, collection_str: Option<&str>, zoom: ZoomVariant) -> &ZoomEffects { 72 53 collection_str 73 54 .and_then(|collection| { ··· 86 67 } 87 68 } 88 69 89 - /// Bloom effect parameters for a single speed/lifetime/size/burst combination. 90 70 #[derive(Clone, Copy)] 91 71 pub struct BloomParams { 92 - /// Radial speed: positive = outward (live), negative = inward (historical). 93 72 pub speed: f32, 94 - /// Particle lifetime in seconds. 95 73 pub lifetime: f32, 96 - /// Initial particle size. 97 74 pub size: f32, 98 - /// Number of particles in the one-shot burst. 99 75 pub burst_count: f32, 100 76 } 101 77 102 - /// Generate a soft radial gradient circle texture for particles. 103 - /// White center fading to transparent edges — gaussian-ish falloff. 78 + /// Soft radial gradient circle texture (gaussian-ish falloff). 104 79 pub fn generate_soft_circle(images: &mut Assets<Image>) -> Handle<Image> { 105 80 const SIZE: u32 = 32; 106 81 let mut data = vec![0u8; (SIZE * SIZE * 4) as usize]; ··· 110 85 let dx = x as f32 + 0.5 - center; 111 86 let dy = y as f32 + 0.5 - center; 112 87 let dist = (dx * dx + dy * dy).sqrt() / center; 113 - // Smooth gaussian-ish falloff 114 88 let alpha = (1.0 - dist * dist).max(0.0).powf(2.0); 115 89 let i = ((y * SIZE + x) * 4) as usize; 116 - data[i] = 255; // R 117 - data[i + 1] = 255; // G 118 - data[i + 2] = 255; // B 119 - data[i + 3] = (alpha * 255.0) as u8; // A 90 + data[i] = 255; 91 + data[i + 1] = 255; 92 + data[i + 2] = 255; 93 + data[i + 3] = (alpha * 255.0) as u8; 120 94 } 121 95 } 122 96 images.add(Image::new( ··· 132 106 )) 133 107 } 134 108 135 - /// Create a particle effect: soft round glowing particles. 136 - /// 137 - /// `speed` is radial velocity: positive = outward (live), negative = inward (historical). 138 - /// Near-zero speed = particles just appear and fade in place. 139 109 pub fn create_bloom_effect(gradient: HanabiGradient<Vec4>, params: BloomParams) -> EffectAsset { 140 110 let writer = ExprWriter::new(); 141 111 142 - // Spawn particles within a tiny sphere 143 112 let init_pos = SetPositionSphereModifier { 144 113 center: writer.lit(Vec3::ZERO).expr(), 145 114 radius: writer.lit(0.005).expr(), 146 115 dimension: ShapeDimension::Volume, 147 116 }; 148 117 149 - // Very slow drift velocity 150 118 let init_vel = SetVelocitySphereModifier { 151 119 center: writer.lit(Vec3::ZERO).expr(), 152 120 speed: writer.lit(params.speed).expr(), ··· 158 126 159 127 let spawner = SpawnerSettings::once(params.burst_count.into()); 160 128 161 - // Size gradient: full size → 0 as particle ages 162 129 let mut size_gradient = HanabiGradient::new(); 163 130 size_gradient.add_key(0.0, Vec3::splat(params.size)); 164 131 size_gradient.add_key(0.8, Vec3::splat(params.size * 0.5)); 165 132 size_gradient.add_key(1.0, Vec3::ZERO); 166 133 167 - // Texture slot must be registered on the module before building the effect 168 134 let slot_zero = writer.lit(0u32).expr(); 169 135 let mut module = writer.finish(); 170 136 module.add_texture_slot("particle"); ··· 189 155 }) 190 156 } 191 157 192 - /// Build a two-key gradient: full-color at t=0, transparent at t=1. 193 - /// Values can exceed 1.0 for HDR bloom effect. 194 158 fn fade_gradient(r: f32, g: f32, b: f32) -> HanabiGradient<Vec4> { 195 159 let mut gradient = HanabiGradient::new(); 196 160 gradient.add_key(0.0, Vec4::new(r, g, b, 1.0)); ··· 199 163 gradient 200 164 } 201 165 202 - /// Build live+historical `ZoomEffects` for a single color and zoom variant. 203 - /// 204 - /// `outward_speed` must be positive; the historical effect uses its negation 205 - /// so particles converge inward (settling onto the helix surface). 206 166 fn make_zoom_effects( 207 167 effects: &mut Assets<EffectAsset>, 208 168 gradient: HanabiGradient<Vec4>, ··· 211 171 size: f32, 212 172 burst_count: f32, 213 173 ) -> ZoomEffects { 214 - let live_params = BloomParams { 215 - speed: outward_speed, 216 - lifetime, 217 - size, 218 - burst_count, 219 - }; 220 - let hist_params = BloomParams { 221 - speed: -outward_speed, 222 - lifetime, 223 - size, 224 - burst_count, 225 - }; 174 + let live_params = BloomParams { speed: outward_speed, lifetime, size, burst_count }; 175 + let hist_params = BloomParams { speed: -outward_speed, lifetime, size, burst_count }; 226 176 ZoomEffects { 227 177 live: effects.add(create_bloom_effect(gradient.clone(), live_params)), 228 178 hist: effects.add(create_bloom_effect(gradient, hist_params)), 229 179 } 230 180 } 231 181 232 - /// Build the default NSID-keyed particle effects and return the resource. 233 - /// 234 - /// Effect color scheme: 235 - /// - Posts: bright blue (HDR boosted for bloom) 236 - /// - Likes: hot pink 237 - /// - Follows: green 238 - /// - bsky_default: dim white (other Bluesky collections) 239 - /// - shiny: gold with larger burst and longer lifetime (non-Bluesky events) 240 - /// 241 - /// Zoom variants: 242 - /// - Outer (level 0–1: hours/minutes): shorter lifetime (0.3 s), smaller size 243 - /// (0.025), slightly higher burst count to create a dense glow cloud 244 - /// - Inner (level 2–3: seconds/sub-second): longer lifetime (0.8 s), larger 245 - /// size (0.06), standard burst for individual event visibility 246 182 pub fn register_default_effects(effects: &mut Assets<EffectAsset>) -> ParticleEffects { 247 - // Soft round particles: slow drift, visible halos 248 - // speed, lifetime, size, burst_count 249 - // Soft round particles: slow drift, visible halos 250 - // speed, lifetime, size, burst_count 251 - // ---- Posts: blue glow ---- 252 183 let post_outer = make_zoom_effects(effects, fade_gradient(0.4, 0.8, 3.0), 0.0, 0.8, 0.008, 3.0); 253 184 let post_inner = make_zoom_effects(effects, fade_gradient(0.4, 0.8, 3.0), 0.0, 1.2, 0.02, 4.0); 254 185 255 - // ---- Likes: pink glow ---- 256 186 let like_outer = make_zoom_effects(effects, fade_gradient(3.0, 0.4, 1.5), 0.0, 0.8, 0.006, 3.0); 257 187 let like_inner = make_zoom_effects(effects, fade_gradient(3.0, 0.4, 1.5), 0.0, 1.2, 0.015, 4.0); 258 188 259 - // ---- Follows: teal glow ---- 260 - let follow_outer = 261 - make_zoom_effects(effects, fade_gradient(0.3, 3.0, 1.0), 0.0, 0.8, 0.008, 4.0); 262 - let follow_inner = 263 - make_zoom_effects(effects, fade_gradient(0.3, 3.0, 1.0), 0.0, 1.2, 0.02, 5.0); 189 + let follow_outer = make_zoom_effects(effects, fade_gradient(0.3, 3.0, 1.0), 0.0, 0.8, 0.008, 4.0); 190 + let follow_inner = make_zoom_effects(effects, fade_gradient(0.3, 3.0, 1.0), 0.0, 1.2, 0.02, 5.0); 264 191 265 - // ---- bsky default: cool dim ---- 266 192 let bsky_default = ZoomPair { 267 193 outer: make_zoom_effects(effects, fade_gradient(1.2, 1.2, 1.5), 0.0, 0.6, 0.005, 2.0), 268 194 inner: make_zoom_effects(effects, fade_gradient(1.2, 1.2, 1.5), 0.0, 1.0, 0.012, 3.0), 269 195 }; 270 196 271 - // ---- Shiny: amber (non-Bluesky AT Protocol events) ---- 272 197 let mut gold_gradient = HanabiGradient::new(); 273 198 gold_gradient.add_key(0.0, Vec4::new(2.0, 1.5, 0.2, 0.7)); 274 199 gold_gradient.add_key(0.5, Vec4::new(1.5, 1.0, 0.1, 0.4)); ··· 279 204 inner: make_zoom_effects(effects, gold_gradient, 0.0, 1.5, 0.025, 5.0), 280 205 }; 281 206 282 - // Build the keyed lookup vec using Collection::NSID for type safety. 283 - // Static str keys avoid any Nsid<S> generic complexity. 284 207 let mut keyed: Vec<(&'static str, ZoomEffects, ZoomEffects)> = vec![ 285 - ( 286 - jacquard::api::app_bsky::feed::post::PostRecord::NSID, 287 - post_outer, 288 - post_inner, 289 - ), 290 - ( 291 - jacquard::api::app_bsky::feed::like::LikeRecord::NSID, 292 - like_outer, 293 - like_inner, 294 - ), 295 - ( 296 - jacquard::api::app_bsky::graph::follow::FollowRecord::NSID, 297 - follow_outer, 298 - follow_inner, 299 - ), 208 + (jacquard::api::app_bsky::feed::post::PostRecord::NSID, post_outer, post_inner), 209 + (jacquard::api::app_bsky::feed::like::LikeRecord::NSID, like_outer, like_inner), 210 + (jacquard::api::app_bsky::graph::follow::FollowRecord::NSID, follow_outer, follow_inner), 300 211 ]; 301 - 302 - // Sort by key string length descending so the first match in a scan is 303 - // always the longest (most specific) prefix match. 304 212 keyed.sort_by_key(|item| std::cmp::Reverse(item.0.len())); 305 213 306 - ParticleEffects { 307 - effects: keyed, 308 - bsky_default, 309 - shiny, 310 - } 214 + ParticleEffects { effects: keyed, bsky_default, shiny } 311 215 } 312 216 313 - /// Soft circle texture handle for particle rendering. 314 217 #[derive(Resource)] 315 218 pub struct ParticleTexture(pub Handle<Image>); 316 219 317 - /// Startup system: build all default particle effects and register the resource. 318 220 pub fn setup_particle_effects( 319 221 mut commands: Commands, 320 222 mut effects: ResMut<Assets<EffectAsset>>, ··· 361 263 } 362 264 } 363 265 364 - /// Resource tracking the current active zoom variant for particle selection. 365 266 #[derive(Resource, Debug)] 366 267 pub struct ZoomLevelState { 367 268 pub current: ZoomVariant, ··· 369 270 370 271 impl Default for ZoomLevelState { 371 272 fn default() -> Self { 372 - ZoomLevelState { 373 - current: ZoomVariant::Outer, 374 - } 273 + ZoomLevelState { current: ZoomVariant::Outer } 375 274 } 376 275 } 377 276 ··· 379 278 let current = ZoomVariant::from_level(state.active_level); 380 279 if zoom_state.current != current { 381 280 zoom_state.current = current; 382 - info!( 383 - "zoom variant → {:?} (helix level {})", 384 - current, state.active_level 385 - ); 281 + info!("zoom variant → {:?} (helix level {})", current, state.active_level); 386 282 } 387 283 } 388 284 389 - /// Frame counter for throttling `cleanup_expired_events`. 390 285 #[derive(Resource, Default)] 391 286 pub struct CleanupTimer { 392 287 frame_count: u32, 393 288 } 394 289 395 - /// Marker attached to event entities once their particle child has been spawned. 396 290 #[derive(Component)] 397 291 pub struct SpawnedAt(pub f32); 398 292 399 - /// How long (seconds) to keep an event entity alive after its particle burst 400 - /// has completed, to allow GPU particles to finish fading. 401 - /// 402 - /// This is set to 2 s — comfortably above the longest particle lifetime in our 403 - /// effects (shiny inner = 1.5 s) to ensure all emitted particles have expired. 404 293 const PARTICLE_GRACE_SECS: f32 = 2.0; 405 - 406 - /// Maximum particle lifetime across all effect variants (shiny inner = 1.5 s). 407 - /// Used as the guaranteed minimum age before we consider a spawner "done emitting 408 - /// and all particles expired." 409 294 const MAX_PARTICLE_LIFETIME_SECS: f32 = 1.5; 410 295 411 296 pub fn cleanup_expired_events( ··· 416 301 window: Res<TimeWindow>, 417 302 mut timer: ResMut<CleanupTimer>, 418 303 ) { 419 - // In unbounded t-space, 1 unit = 1 minute. Keep 2 minutes of data. 420 304 const CLEANUP_MARGIN: f32 = 2.0; 421 305 422 - // Throttle: only run every 30 frames. At 60 fps this is ~0.5 s, which is 423 - // well within the 3.5 s minimum age before any entity becomes eligible. 424 306 timer.frame_count += 1; 425 307 if !timer.frame_count.is_multiple_of(30) { 426 308 return; ··· 435 317 for (entity, event_marker, spawned_at) in event_query.iter() { 436 318 let t = window.time_to_t(event_marker.time_us()); 437 319 let distance_from_focal = (t - state.focal_time).abs(); 438 - let is_off_screen = distance_from_focal > CLEANUP_MARGIN; 439 320 440 - if !is_off_screen { 321 + if distance_from_focal <= CLEANUP_MARGIN { 441 322 continue; 442 323 } 443 324 444 - // If it has particles, wait for them to expire before despawning. 445 - // If no particles (historical events), despawn immediately when off-screen. 446 325 let should_despawn = match spawned_at { 447 - Some(sa) => { 448 - let age = now - sa.0; 449 - age >= MAX_PARTICLE_LIFETIME_SECS + PARTICLE_GRACE_SECS 450 - } 326 + Some(sa) => (now - sa.0) >= MAX_PARTICLE_LIFETIME_SECS + PARTICLE_GRACE_SECS, 451 327 None => true, 452 328 }; 453 329 ··· 462 338 use super::*; 463 339 use bevy::asset::Handle; 464 340 465 - // ----------------------------------------------------------------------- 466 - // Helper: build a minimal ParticleEffects for testing the lookup logic. 467 - // 468 - // Uses Handle::default() for all asset handles — valid for testing the pure 469 - // NSID matching logic without requiring a Bevy World or asset server. 470 - // ----------------------------------------------------------------------- 471 - 472 341 fn dummy_zoom_effects() -> ZoomEffects { 473 342 ZoomEffects { 474 343 live: Handle::default(), ··· 483 352 } 484 353 } 485 354 486 - /// Build a ParticleEffects with post/like/follow registered, using the same 487 - /// NSID constants the production code uses. 488 355 fn test_particle_effects() -> ParticleEffects { 489 356 use jacquard::api::app_bsky::feed::like::LikeRecord; 490 357 use jacquard::api::app_bsky::feed::post::PostRecord; ··· 493 360 let mut keyed: Vec<(&'static str, ZoomEffects, ZoomEffects)> = vec![ 494 361 (PostRecord::NSID, dummy_zoom_effects(), dummy_zoom_effects()), 495 362 (LikeRecord::NSID, dummy_zoom_effects(), dummy_zoom_effects()), 496 - ( 497 - FollowRecord::NSID, 498 - dummy_zoom_effects(), 499 - dummy_zoom_effects(), 500 - ), 363 + (FollowRecord::NSID, dummy_zoom_effects(), dummy_zoom_effects()), 501 364 ]; 502 365 keyed.sort_by_key(|item| std::cmp::Reverse(item.0.len())); 503 366 ··· 508 371 } 509 372 } 510 373 511 - // ----------------------------------------------------------------------- 512 - // I2: NSID prefix matching tests 513 - // ----------------------------------------------------------------------- 514 - 515 374 #[test] 516 375 fn lookup_post_nsid_matches_post_entry() { 517 376 use jacquard::api::app_bsky::feed::post::PostRecord; 518 377 let effects = test_particle_effects(); 519 - // The lookup should return the post-specific ZoomEffects (outer variant). 520 - // We can't compare Handle<EffectAsset> by value, so we verify the call 521 - // doesn't fall through to bsky_default or shiny by checking it returns 522 - // a different pointer than the dummy_zoom_pair singletons. 523 - // 524 - // Since all handles are Handle::default(), we use a pointer identity 525 - // check on the returned ZoomEffects via their address. 526 378 let result = effects.lookup(Some(PostRecord::NSID), ZoomVariant::Outer); 527 - // The result must be one of the registered per-NSID entries, not bsky_default or shiny. 528 379 let result_ptr = result as *const ZoomEffects; 529 380 let bsky_ptr = effects.bsky_default.select(ZoomVariant::Outer) as *const ZoomEffects; 530 381 let shiny_ptr = effects.shiny.select(ZoomVariant::Outer) as *const ZoomEffects; 531 - assert_ne!( 532 - result_ptr, bsky_ptr, 533 - "post NSID matched bsky_default instead of post entry" 534 - ); 535 - assert_ne!( 536 - result_ptr, shiny_ptr, 537 - "post NSID matched shiny instead of post entry" 538 - ); 382 + assert_ne!(result_ptr, bsky_ptr, "post NSID matched bsky_default instead of post entry"); 383 + assert_ne!(result_ptr, shiny_ptr, "post NSID matched shiny instead of post entry"); 539 384 } 540 385 541 386 #[test] 542 387 fn lookup_unknown_bsky_collection_falls_to_bsky_default() { 543 388 let effects = test_particle_effects(); 544 - // An unregistered app.bsky.* NSID should fall through to bsky_default. 545 389 let result = effects.lookup(Some("app.bsky.actor.something"), ZoomVariant::Outer); 546 390 let result_ptr = result as *const ZoomEffects; 547 391 let bsky_ptr = effects.bsky_default.select(ZoomVariant::Outer) as *const ZoomEffects; 548 - assert_eq!( 549 - result_ptr, bsky_ptr, 550 - "unknown app.bsky.* NSID should fall to bsky_default" 551 - ); 392 + assert_eq!(result_ptr, bsky_ptr, "unknown app.bsky.* should fall to bsky_default"); 552 393 } 553 394 554 395 #[test] 555 396 fn lookup_non_bsky_collection_falls_to_shiny() { 556 397 let effects = test_particle_effects(); 557 - // A non-bsky NSID (e.g. a third-party collection) should fall to shiny. 558 398 let result = effects.lookup(Some("com.example.some.collection"), ZoomVariant::Outer); 559 399 let result_ptr = result as *const ZoomEffects; 560 400 let shiny_ptr = effects.shiny.select(ZoomVariant::Outer) as *const ZoomEffects; ··· 564 404 #[test] 565 405 fn lookup_none_collection_falls_to_shiny() { 566 406 let effects = test_particle_effects(); 567 - // None (Identity/Account events — no collection NSID) should fall to shiny. 568 407 let result = effects.lookup(None, ZoomVariant::Outer); 569 408 let result_ptr = result as *const ZoomEffects; 570 409 let shiny_ptr = effects.shiny.select(ZoomVariant::Outer) as *const ZoomEffects; 571 - assert_eq!( 572 - result_ptr, shiny_ptr, 573 - "None collection (Identity/Account) should fall to shiny" 574 - ); 410 + assert_eq!(result_ptr, shiny_ptr, "None collection should fall to shiny"); 575 411 } 576 412 577 413 #[test] 578 414 fn lookup_inner_zoom_variant_selects_inner_effects() { 579 415 let effects = test_particle_effects(); 580 - // Verify ZoomVariant::Inner returns the inner slot, not the outer. 581 416 let result_outer = effects.lookup(None, ZoomVariant::Outer) as *const ZoomEffects; 582 417 let result_inner = effects.lookup(None, ZoomVariant::Inner) as *const ZoomEffects; 583 - assert_ne!( 584 - result_outer, result_inner, 585 - "outer and inner zoom variants should return different ZoomEffects" 586 - ); 418 + assert_ne!(result_outer, result_inner, "outer and inner should return different ZoomEffects"); 587 419 } 588 420 }
+74 -10
src/slides.rs
··· 1 + use bevy::asset::io::Reader; 2 + use bevy::asset::{AssetLoader, LoadContext}; 1 3 use bevy::prelude::*; 2 4 use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd, TextMergeStream}; 5 + 3 6 4 7 #[derive(Debug, Clone)] 5 8 pub struct StyledSpan { ··· 23 26 pub fragments: Vec<SlideFragment>, 24 27 } 25 28 29 + /// Bevy asset: a parsed slide deck loaded from a markdown file. 30 + #[derive(Asset, TypePath, Debug, Clone)] 31 + pub struct SlideDeckAsset { 32 + pub slides: Vec<Slide>, 33 + } 34 + 35 + /// Resource mirror of the loaded slide deck, updated on asset changes. 26 36 #[derive(Resource, Default)] 27 37 pub struct SlideDeck { 28 38 pub slides: Vec<Slide>, 29 39 } 30 40 41 + /// Handle to the loaded slide deck asset. 42 + #[derive(Resource)] 43 + pub struct SlideDeckHandle(pub Handle<SlideDeckAsset>); 44 + 31 45 /// `None` = normal event card, `Some(index)` = showing slide. 32 46 #[derive(Resource, Default)] 33 47 pub struct SlideMode(pub Option<usize>); 48 + 49 + 50 + #[derive(Default, TypePath)] 51 + pub struct SlideDeckLoader; 52 + 53 + impl AssetLoader for SlideDeckLoader { 54 + type Asset = SlideDeckAsset; 55 + type Settings = (); 56 + type Error = std::io::Error; 57 + 58 + async fn load( 59 + &self, 60 + reader: &mut dyn Reader, 61 + _settings: &(), 62 + _load_context: &mut LoadContext<'_>, 63 + ) -> Result<Self::Asset, Self::Error> { 64 + let mut bytes = Vec::new(); 65 + reader.read_to_end(&mut bytes).await?; 66 + let markdown = 67 + String::from_utf8(bytes).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; 68 + let slides = parse_slide_deck(&markdown); 69 + Ok(SlideDeckAsset { slides }) 70 + } 71 + 72 + fn extensions(&self) -> &[&str] { 73 + &["md"] 74 + } 75 + } 76 + 34 77 35 78 fn parse_slide(markdown: &str) -> Slide { 36 79 let options = Options::empty(); ··· 176 219 slides 177 220 } 178 221 179 - pub fn load_slide_deck(mut commands: Commands) { 180 - let path = std::path::Path::new("assets/slides.md"); 181 - match std::fs::read_to_string(path) { 182 - Ok(markdown) => { 183 - let slides = parse_slide_deck(&markdown); 184 - info!("Loaded slide deck: {} slides", slides.len()); 185 - commands.insert_resource(SlideDeck { slides }); 222 + 223 + /// Startup: kick off the asset load. 224 + pub fn load_slide_deck(mut commands: Commands, asset_server: Res<AssetServer>) { 225 + let handle = asset_server.load::<SlideDeckAsset>("slides.md"); 226 + commands.insert_resource(SlideDeckHandle(handle)); 227 + } 228 + 229 + /// Sync the `SlideDeck` resource when the asset is loaded or hot-reloaded. 230 + pub fn sync_slide_deck( 231 + mut events: MessageReader<AssetEvent<SlideDeckAsset>>, 232 + assets: Res<Assets<SlideDeckAsset>>, 233 + handle: Option<Res<SlideDeckHandle>>, 234 + mut deck: ResMut<SlideDeck>, 235 + mut render_state: ResMut<crate::event_card::CardRenderState>, 236 + ) { 237 + let Some(handle) = handle else { return }; 238 + 239 + for event in events.read() { 240 + let id = match event { 241 + AssetEvent::Added { id } | AssetEvent::Modified { id } => id, 242 + _ => continue, 243 + }; 244 + 245 + if *id != handle.0.id() { 246 + continue; 186 247 } 187 - Err(e) => { 188 - warn!("Could not load slides.md: {e}"); 189 - commands.insert_resource(SlideDeck::default()); 248 + 249 + if let Some(asset) = assets.get(*id) { 250 + let count = asset.slides.len(); 251 + deck.slides = asset.slides.clone(); 252 + render_state.slide_generation += 1; 253 + info!("Slide deck reloaded: {count} slides"); 190 254 } 191 255 } 192 256 }
-197
src/widgets.rs
··· 1 - //! Simple widgets for example UI. 2 - //! 3 - //! Unlike other examples, which demonstrate an application, this demonstrates a plugin library. 4 - 5 - use bevy::prelude::*; 6 - 7 - /// An event that's sent whenever the user changes one of the settings by 8 - /// clicking a radio button. 9 - #[derive(Clone, Message, Deref, DerefMut)] 10 - pub struct WidgetClickEvent<T>(T); 11 - 12 - /// A marker component that we place on all widgets that send 13 - /// [`WidgetClickEvent`]s of the given type. 14 - #[derive(Clone, Component, Deref, DerefMut)] 15 - pub struct WidgetClickSender<T>(T) 16 - where 17 - T: Clone + Send + Sync + 'static; 18 - 19 - /// A marker component that we place on all radio `Button`s. 20 - #[derive(Clone, Copy, Component)] 21 - pub struct RadioButton; 22 - 23 - /// A marker component that we place on all `Text` inside radio buttons. 24 - #[derive(Clone, Copy, Component)] 25 - pub struct RadioButtonText; 26 - 27 - /// The size of the border that surrounds buttons. 28 - pub const BUTTON_BORDER: UiRect = UiRect::all(Val::Px(1.0)); 29 - 30 - /// The color of the border that surrounds buttons. 31 - pub const BUTTON_BORDER_COLOR: BorderColor = BorderColor { 32 - left: Color::WHITE, 33 - right: Color::WHITE, 34 - top: Color::WHITE, 35 - bottom: Color::WHITE, 36 - }; 37 - 38 - /// The amount of rounding to apply to button corners. 39 - pub const BUTTON_BORDER_RADIUS_SIZE: Val = Val::Px(6.0); 40 - 41 - /// The amount of space between the edge of the button and its label. 42 - pub const BUTTON_PADDING: UiRect = UiRect::axes(Val::Px(12.0), Val::Px(6.0)); 43 - 44 - /// Returns a [`Node`] appropriate for the outer main UI node. 45 - /// 46 - /// This UI is in the bottom left corner and has flex column support 47 - pub fn main_ui_node() -> Node { 48 - Node { 49 - flex_direction: FlexDirection::Column, 50 - position_type: PositionType::Absolute, 51 - row_gap: px(6), 52 - left: px(10), 53 - bottom: px(10), 54 - ..default() 55 - } 56 - } 57 - 58 - /// Spawns a single radio button that allows configuration of a setting. 59 - /// 60 - /// The type parameter specifies the value that will be packaged up and sent in 61 - /// a [`WidgetClickEvent`] when the radio button is clicked. 62 - pub fn option_button<T>( 63 - option_value: T, 64 - option_name: &str, 65 - is_selected: bool, 66 - is_first: bool, 67 - is_last: bool, 68 - ) -> impl Bundle 69 - where 70 - T: Clone + Send + Sync + 'static, 71 - { 72 - let (bg_color, fg_color) = if is_selected { 73 - (Color::WHITE, Color::BLACK) 74 - } else { 75 - (Color::BLACK, Color::WHITE) 76 - }; 77 - 78 - // Add the button node. 79 - ( 80 - Button, 81 - Node { 82 - border: BUTTON_BORDER.with_left(if is_first { px(1) } else { px(0) }), 83 - justify_content: JustifyContent::Center, 84 - align_items: AlignItems::Center, 85 - padding: BUTTON_PADDING, 86 - border_radius: BorderRadius::ZERO 87 - .with_left(if is_first { 88 - BUTTON_BORDER_RADIUS_SIZE 89 - } else { 90 - px(0) 91 - }) 92 - .with_right(if is_last { 93 - BUTTON_BORDER_RADIUS_SIZE 94 - } else { 95 - px(0) 96 - }), 97 - ..default() 98 - }, 99 - BUTTON_BORDER_COLOR, 100 - BackgroundColor(bg_color), 101 - RadioButton, 102 - WidgetClickSender(option_value.clone()), 103 - children![( 104 - ui_text(option_name, fg_color), 105 - RadioButtonText, 106 - WidgetClickSender(option_value), 107 - )], 108 - ) 109 - } 110 - 111 - /// Spawns the buttons that allow configuration of a setting. 112 - /// 113 - /// The user may change the setting to any one of the labeled `options`. The 114 - /// value of the given type parameter will be packaged up and sent as a 115 - /// [`WidgetClickEvent`] when one of the radio buttons is clicked. 116 - pub fn option_buttons<T>(title: &str, options: &[(T, &str)]) -> impl Bundle 117 - where 118 - T: Clone + Send + Sync + 'static, 119 - { 120 - let buttons = options 121 - .iter() 122 - .cloned() 123 - .enumerate() 124 - .map(|(option_index, (option_value, option_name))| { 125 - option_button( 126 - option_value, 127 - option_name, 128 - option_index == 0, 129 - option_index == 0, 130 - option_index == options.len() - 1, 131 - ) 132 - }) 133 - .collect::<Vec<_>>(); 134 - // Add the parent node for the row. 135 - ( 136 - Node { 137 - align_items: AlignItems::Center, 138 - ..default() 139 - }, 140 - Children::spawn(( 141 - Spawn(( 142 - ui_text(title, Color::BLACK), 143 - Node { 144 - width: px(125), 145 - ..default() 146 - }, 147 - )), 148 - SpawnIter(buttons.into_iter()), 149 - )), 150 - ) 151 - } 152 - 153 - /// Creates a text bundle for the UI. 154 - pub fn ui_text(label: &str, color: Color) -> impl Bundle + use<> { 155 - ( 156 - Text::new(label), 157 - TextFont { 158 - font_size: 18.0, 159 - ..default() 160 - }, 161 - TextColor(color), 162 - ) 163 - } 164 - 165 - /// Checks for clicks on the radio buttons and sends `RadioButtonChangeEvent`s 166 - /// as necessary. 167 - pub fn handle_ui_interactions<T>( 168 - mut interactions: Query< 169 - (&Interaction, &WidgetClickSender<T>), 170 - (With<Button>, With<RadioButton>), 171 - >, 172 - mut widget_click_events: MessageWriter<WidgetClickEvent<T>>, 173 - ) where 174 - T: Clone + Send + Sync + 'static, 175 - { 176 - for (interaction, click_event) in interactions.iter_mut() { 177 - if *interaction == Interaction::Pressed { 178 - widget_click_events.write(WidgetClickEvent((**click_event).clone())); 179 - } 180 - } 181 - } 182 - 183 - /// Updates the style of the button part of a radio button to reflect its 184 - /// selected status. 185 - pub fn update_ui_radio_button(background_color: &mut BackgroundColor, selected: bool) { 186 - background_color.0 = if selected { Color::WHITE } else { Color::BLACK }; 187 - } 188 - 189 - /// Updates the color of the label of a radio button to reflect its selected 190 - /// status. 191 - pub fn update_ui_radio_button_text(entity: Entity, writer: &mut TextUiWriter, selected: bool) { 192 - let text_color = if selected { Color::BLACK } else { Color::WHITE }; 193 - 194 - writer.for_each_color(entity, |mut color| { 195 - color.0 = text_color; 196 - }); 197 - }