···11+[application]
22+33+# Web `build` & `serve` dist path
44+out_dir = "dist"
55+66+# resource (static) file folder
77+asset_dir = "public"
88+99+[web.wasm_opt]
1010+# The level wasm-opt should target. z is the smallest. 4 is the fastest.
1111+level = "4"
1212+1313+[web.app]
1414+1515+# HTML title tag content
1616+title = "Dioxus | An elegant GUI library for Rust"
1717+1818+[web.watcher]
1919+2020+index_on_404 = true
2121+2222+watch_path = ["src", "examples"]
2323+2424+[bundle]
2525+# Bundle identifier
2626+identifier = "io.github.magic"
2727+2828+# Bundle publisher
2929+publisher = "magic"
3030+3131+# Bundle icon
3232+icon = ["icons/icon.png"]
3333+3434+# Bundle resources
3535+resources = ["public/*"]
3636+3737+# Bundle copyright
3838+copyright = ""
3939+4040+# Bundle category
4141+category = "Utility"
4242+4343+# Bundle short description
4444+short_description = "An amazing dioxus application."
4545+4646+# Bundle long description
4747+long_description = """
4848+An amazing dioxus application.
4949+"""
+66-16
assets/slides.md
···11-# Jacquard Talk
11+# Jacquard Magic
22+33+Making atproto actually easy with Rust
44+55+---
66+77+## Spite-driven development
88+99+- Existing atproto libraries kinda sucked to work with
1010+- Couple of notable exceptions, but none in Rust
1111+- Put a project on hold because I got frustrated with atrium
1212+- Friends working on atproto stuff in Rust had similar frustrations
1313+1414+---
1515+1616+## Code Generation
1717+1818+- Going from lexicon to app, generate API bindings
1919+- Then spend way more time (or LLM tokens) writing code around them to make them usable
2020+2121+---
22233-Real-time 3D visualization of the AT Protocol firehose
2323+Not just an atproto problem, but hurts more here, especially for those not wanting to just play in Bluesky's sandbox.
424525---
62677-## What is the AT Protocol?
2727+# We don't have to live like this
82899-- A federated social networking protocol
1010-- **Jetstream** provides a real-time WebSocket firehose
1111-- Every post, like, follow, and identity event flows through it
2929+3030+---
3131+3232+## Why Rust?
3333+3434+Only language where you can actually run it anywhere
3535+3636+and it's not a stupid idea to.
3737+3838+---
3939+4040+## Why Rust?
4141+4242+- Rust has a reputation as a difficult and demanding language
4343+- That reputation is sort of deserved
4444+4545+4646+---
4747+4848+## Why Rust?
4949+5050+- The things Rust asks of you are things you often need to think about anyway
5151+- It just makes them explicit
5252+- We can make the sharp edges easier, if we care to
5353+5454+---
5555+5656+# We don't have to live like this!
12571358---
14591560## What you're seeing
16611717-- Each **particle** is a real event from the network
1818-- Blue = posts, pink = likes, green = follows
1919-- Gold = non-Bluesky AT Protocol events
2020-- The helix maps **time** to **space**
6262+- Each dot is a Jetstream event
6363+- When you select one, we hit
6464+ - Constellation for interactions
6565+ - Slingshot for records
6666+ - Bluesky appview for profiles
21672268---
23692424-## The Stack
7070+# But...
25712626-- **Bevy** for 3D rendering and ECS
2727-- **Jacquard** for AT Protocol types
2828-- **bevy_hanabi** for GPU particle effects
2929-- **SQLite** for offline replay
3030-- All async runs on **smol**, not tokio
7272+7373+---
7474+7575+## HOW?
7676+7777+- Standing upon the shoulders of giants here
7878+- Big party trick is the **subsecond** library
7979+- Bevy engine with hot patch support
8080+31813282---
3383
-41
src/db.rs
···11//! SQLite persistence layer for Jetstream events.
22-//!
33-//! Two dedicated OS threads, each with its own connection to the same DB file:
44-//! - **Writer thread**: handles batch inserts. Never blocks on reads.
55-//! - **Reader thread**: handles queries. Never blocks on writes.
66-//!
77-//! SQLite WAL mode allows concurrent reads alongside a single writer.
88-//! Splitting the threads eliminates read/write contention that caused
99-//! periodic hitching when both operations shared a single thread.
102113use bevy::prelude::*;
124use crossbeam_channel::{Receiver, Sender};
135use jacquard::types::string::{Did, Nsid};
146use rusqlite::{Connection, params};
1571616-// ---------------------------------------------------------------------------
1717-// Request / response types
1818-// ---------------------------------------------------------------------------
1919-2020-/// Write requests — handled by the dedicated writer thread.
218pub enum DbWriteRequest {
229 WriteEvents(Vec<DbEvent>),
2310}
24112525-/// Read requests — handled by the dedicated reader thread.
2612pub enum DbReadRequest {
2713 QueryTimeRange {
2814 start_us: i64,
···4329 pub message_json: String,
4430}
45314646-/// Combined resource for backward compatibility with code that uses `DbChannel`.
4732#[derive(Resource, Clone)]
4833pub struct DbChannel {
4934 pub writer: Sender<DbWriteRequest>,
5035 pub reader: Sender<DbReadRequest>,
5136}
52375353-// ---------------------------------------------------------------------------
5454-// Schema
5555-// ---------------------------------------------------------------------------
5656-5738const CREATE_EVENTS_TABLE: &str = "
5839CREATE TABLE IF NOT EXISTS events (
5940 id INTEGER PRIMARY KEY,
···7152 "CREATE INDEX IF NOT EXISTS idx_events_collection ON events(collection);";
7253const CREATE_IDX_DID: &str = "CREATE INDEX IF NOT EXISTS idx_events_did ON events(did);";
73547474-// ---------------------------------------------------------------------------
7575-// Startup
7676-// ---------------------------------------------------------------------------
7777-7855pub fn spawn_db_thread(mut commands: Commands) {
7956 let (write_tx, write_rx) = crossbeam_channel::bounded::<DbWriteRequest>(256);
8057 let (read_tx, read_rx) = crossbeam_channel::bounded::<DbReadRequest>(64);
81588282- // Writer thread — dedicated connection for inserts
8359 std::thread::spawn(move || {
8460 run_writer(write_rx);
8561 });
86628787- // Reader thread — dedicated connection for queries
8863 std::thread::spawn(move || {
8964 run_reader(read_rx);
9065 });
···9671 info!("SQLite writer + reader threads started");
9772}
98739999-// ---------------------------------------------------------------------------
100100-// Writer thread
101101-// ---------------------------------------------------------------------------
102102-10374fn run_writer(rx: Receiver<DbWriteRequest>) {
10475 let conn = match open_and_init() {
10576 Some(c) => c,
···12192 }
12293}
12394124124-// ---------------------------------------------------------------------------
125125-// Reader thread
126126-// ---------------------------------------------------------------------------
127127-12895fn run_reader(rx: Receiver<DbReadRequest>) {
12996 let conn = match open_and_init() {
13097 Some(c) => c,
···166133 }
167134}
168135169169-// ---------------------------------------------------------------------------
170170-// Shared helpers
171171-// ---------------------------------------------------------------------------
172172-173136fn open_and_init() -> Option<Connection> {
174137 let conn = match Connection::open("jetstream_events.db") {
175138 Ok(c) => c,
···216179 conn.execute_batch(CREATE_IDX_DID)?;
217180 Ok(())
218181}
219219-220220-// ---------------------------------------------------------------------------
221221-// SQL operations
222222-// ---------------------------------------------------------------------------
223182224183fn write_events(conn: &Connection, events: &[DbEvent]) -> rusqlite::Result<()> {
225184 if events.is_empty() {
···11use bevy::prelude::*;
2233-/// A Frenet-Serret frame: tangent, normal, binormal at a point on the helix.
33+/// Frenet-Serret frame at a point on the helix.
44#[derive(Debug, Clone, Copy)]
55pub struct Frame {
66 pub position: Vec3,
77- /// Tangent vector along the curve. Reserved for #[hot] use (e.g. particle
88- /// orientation) — suppress dead_code while the feature is in progress.
97 #[allow(dead_code)]
108 pub tangent: Vec3,
119 pub normal: Vec3,
1210 pub binormal: Vec3,
1311}
14121515-/// Describes one level of the nested helix hierarchy.
1613#[derive(Debug, Clone)]
1714pub struct HelixLevel {
1818- /// How many times this level wraps around its parent per unit of t.
1915 pub turns_per_parent: f32,
2020- /// How many sample points to evaluate per turn for rendering.
2116 pub samples_per_turn: u32,
2222- /// Display label for this level. Reserved for #[hot] UI overlays — suppress
2323- /// dead_code while the feature is in progress.
2417 #[allow(dead_code)]
2518 pub label: &'static str,
2626- /// Color for gizmo rendering.
2719 pub color: Color,
2828- /// Extra multiplier on this level's coil radius. Default 1.0.
2920 pub radius_scale: f32,
3021}
31223232-/// The full hierarchy of nested helix levels.
3333-/// Outermost level is index 0 (hours), innermost is last (sub-second).
2323+/// Outermost level is index 0, innermost is last.
3424#[derive(Resource, Debug, Clone)]
3525pub struct HelixHierarchy {
3626 pub levels: Vec<HelixLevel>,
3737- /// Fraction of parent radius used for child coil radius.
3827 pub fill_factor: f32,
3939- /// Base radius of the outermost helix.
4028 pub base_radius: f32,
4141- /// Vertical spacing per turn of the outermost helix (units per 1.0 of t).
4229 pub pitch_per_turn: f32,
4330}
44314545-/// Current focal time and active zoom level for the helix view.
4646-///
4747-/// `focal_time` is in the same unbounded t-space as eval_coil — one unit of t
4848-/// equals one turn of the outermost helix. It advances continuously as new
4949-/// events arrive (when auto_follow is true).
3232+/// 1 unit of focal_time = 1 outermost helix turn = 1 minute.
5033#[derive(Resource, Debug)]
5134pub struct HelixState {
5252- /// Focal time in unbounded t-space (1 unit = 1 outermost turn).
5335 pub focal_time: f32,
5454- /// Currently active zoom level index into HelixHierarchy.levels.
5536 pub active_level: usize,
5656- /// Smooth interpolation target for active_level (float for animation).
5737 pub target_level: f32,
5858- /// Continuous interpolated level for smooth alpha blending in visualization.
5938 pub interpolated_level: f32,
6060- /// Whether focal_time should auto-advance to track the latest data.
6139 pub auto_follow: bool,
6262- /// Time tracking speed: 1.0 = real-time, 0.0 = frozen.
6363- /// When < 1.0, auto_follow lerps toward latest_t instead of snapping.
6440 pub time_scale: f32,
6541}
66426767-/// Create default helix hierarchy with sensible parameters.
6868-///
6969-/// One unit of t = one turn of the outermost helix = one minute of real time
7070-/// (configured via TimeWindow::MICROS_PER_TURN).
7143pub fn default_hierarchy() -> HelixHierarchy {
7244 HelixHierarchy {
7345 levels: vec![
···10577 }
10678}
10779108108-/// Evaluate the helix frame at parameter t through all levels up to `level`.
109109-///
110110-/// Uses iterative Gram-Schmidt orthogonalization (following the time-helix
111111-/// reference implementation) instead of a fixed up-vector cross product.
112112-/// This avoids frame discontinuities when the tangent is near-parallel to
113113-/// any fixed axis.
114114-///
115115-/// t is unbounded — one unit = one turn of the outermost helix.
8080+/// Evaluate helix frame at t through all levels up to `level`.
8181+/// Uses iterative Gram-Schmidt to avoid frame discontinuities.
11682pub fn eval_coil(t: f32, level: usize, hierarchy: &HelixHierarchy) -> Frame {
11783 use std::f32::consts::TAU;
11884···12187 let theta = t * TAU;
12288 let (s, c) = theta.sin_cos();
12389124124- // Level 0: base helix position and tangent
12590 let mut px = r * c;
12691 let mut py = t * p;
12792 let mut pz = r * s;
12893129129- // Tangent = derivative of position w.r.t. t
13094 let mut dtx = -r * TAU * s;
13195 let mut dty = p;
13296 let mut dtz = r * TAU * c;
13397134134- // Initial normal: radial direction (inward toward helix axis)
13598 let mut nx = -c;
13699 let mut ny = 0.0_f32;
137100 let mut nz = -s;
138101139139- // Binormal from tangent × normal
140102 let t_len = (dtx * dtx + dty * dty + dtz * dtz).sqrt();
141103 let (mut tx, mut ty, mut tz) = (dtx / t_len, dty / t_len, dtz / t_len);
142104 let mut bx = ty * nz - tz * ny;
143105 let mut by = tz * nx - tx * nz;
144106 let mut bz = tx * ny - ty * nx;
145107146146- // Iterate through child levels
147108 for lvl in 1..=level {
148109 let level_info = &hierarchy.levels[lvl];
149110···156117 .product();
157118 let child_r = hierarchy.base_radius * depth_scale;
158119159159- // Cumulative total turns at this level — product of all turns_per_parent up to here.
160120 let total_turns: f32 = hierarchy.levels[..=lvl]
161121 .iter()
162122 .map(|l| l.turns_per_parent)
···165125 let (sa, ca) = alpha.sin_cos();
166126 let w = total_turns * TAU;
167127168168- // Offset from parent along normal/binormal plane
169128 let dx = nx * child_r * ca + bx * child_r * sa;
170129 let dy = ny * child_r * ca + by * child_r * sa;
171130 let dz = nz * child_r * ca + bz * child_r * sa;
···174133 py += dy;
175134 pz += dz;
176135177177- // Update tangent: add derivative of the child offset
178178- // d(offset)/dt = child_r * w * (-sin(alpha) * normal + cos(alpha) * binormal)
179136 let ex = -nx * child_r * sa * w + bx * child_r * ca * w;
180137 let ey = -ny * child_r * sa * w + by * child_r * ca * w;
181138 let ez = -nz * child_r * sa * w + bz * child_r * ca * w;
···189146 ty = dty / t_len;
190147 tz = dtz / t_len;
191148192192- // Gram-Schmidt: orthogonalize offset direction against new tangent
193193- // to get the new normal (rotation-minimizing frame)
194149 let dot = dx * tx + dy * ty + dz * tz;
195150 let mut nnx = dx - dot * tx;
196151 let mut nny = dy - dot * ty;
···204159 ny = nny;
205160 nz = nnz;
206161207207- // Recompute binormal from tangent × normal
208162 bx = ty * nz - tz * ny;
209163 by = tz * nx - tx * nz;
210164 bz = tx * ny - ty * nx;
···218172 }
219173}
220174221221-/// Compute the 3D position on the helix where the camera should focus.
222222-/// Always tracks level 0 (outermost helix) so the camera doesn't jump
223223-/// when the user zooms between levels.
224175pub fn compute_focal_point(state: &HelixState, hierarchy: &HelixHierarchy) -> Vec3 {
225176 let frame = eval_coil(state.focal_time, 0, hierarchy);
226177 frame.position
227178}
228179229229-/// Precomputed helix geometry buffer. Computed once at startup and extended
230230-/// lazily as time progresses. Each frame, `draw_helix` just slices the
231231-/// visible window from this buffer — zero eval_coil calls per frame.
232180#[derive(Resource)]
233181pub struct HelixGeometry {
234234- /// Per-level precomputed vertex buffers.
235235- /// Each entry: (t_start, t_step, positions).
236182 pub levels: Vec<HelixLevelGeometry>,
237237- /// How far ahead (in t-space) we've precomputed.
238183 pub precomputed_to: f32,
239184}
240185···244189 pub positions: Vec<Vec3>,
245190}
246191247247-/// Samples per unit of t for each level when precomputing.
248248-/// Innermost levels get more samples for smoother curves.
249192fn samples_per_t(level_idx: usize, hierarchy: &HelixHierarchy) -> f32 {
250193 let total_turns: f32 = hierarchy.levels[..=level_idx]
251194 .iter()
252195 .map(|l| l.turns_per_parent)
253196 .product();
254254- // Each turn gets samples_per_turn points. Innermost level uses 64 for smoothness.
255197 let spt = if level_idx == hierarchy.levels.len() - 1 {
256198 64.0
257199 } else {
···260202 total_turns * spt
261203}
262204263263-/// How much t-space to precompute ahead of current time. Generous buffer
264264-/// so we don't need to extend frequently.
265265-const PRECOMPUTE_AHEAD: f32 = 30.0; // 30 minutes
266266-const PRECOMPUTE_BEHIND: f32 = 10.0; // 5 minutes behind
205205+const PRECOMPUTE_AHEAD: f32 = 30.0;
206206+const PRECOMPUTE_BEHIND: f32 = 10.0;
267207268268-/// Build the initial helix geometry buffer.
269208pub fn setup_helix_geometry(mut commands: Commands, hierarchy: Res<HelixHierarchy>) {
270209 let t_start = -PRECOMPUTE_BEHIND;
271210 let t_end = PRECOMPUTE_AHEAD;
···301240 });
302241}
303242304304-/// Extend the precomputed buffer if focal_time is approaching the end.
305243pub fn extend_helix_geometry(
306244 state: Res<HelixState>,
307245 hierarchy: Res<HelixHierarchy>,
308246 mut geometry: ResMut<HelixGeometry>,
309247) {
310310- // Extend when we're within 5 minutes of the precomputed boundary.
311248 if state.focal_time + 5.0 < geometry.precomputed_to {
312249 return;
313250 }
···331268 geometry.precomputed_to = new_end;
332269}
333270334334-/// Cached retained gizmo state.
335271#[derive(Resource)]
336272pub struct HelixGizmoCache {
337273 pub last_focal_time: f32,
···351287 }
352288}
353289354354-/// Draw helix using retained GizmoAsset from precomputed geometry slices.
355355-/// All levels are visible. Rebuilds asset only when the view changes.
356290pub fn draw_helix(
357291 mut commands: Commands,
358292 hierarchy: Res<HelixHierarchy>,
···403337 }
404338 }
405339406406- // Reuse handle — never leak assets
407340 match &cache.asset_handle {
408341 Some(h) => {
409342 let _ = gizmo_assets.insert(h.id(), gizmo);
···429362mod tests {
430363 use super::*;
431364432432- // -----------------------------------------------------------------------
433433- // Helpers
434434- // -----------------------------------------------------------------------
435365436366 /// Assert all components of a Vec3 are finite (non-NaN and non-Inf).
437367 fn assert_finite(v: Vec3, label: &str) {
···449379 );
450380 }
451381452452- // -----------------------------------------------------------------------
453453- // I1a: default_hierarchy() structure
454454- // -----------------------------------------------------------------------
455382456383 #[test]
457384 fn default_hierarchy_has_three_levels_with_positive_params() {
···472399 }
473400 }
474401475475- // -----------------------------------------------------------------------
476476- // I1b: eval_coil level-0 produces finite positions over a wide t range
477477- // -----------------------------------------------------------------------
478402479403 #[test]
480404 fn eval_coil_level0_finite_for_extreme_t_values() {
···488412 }
489413 }
490414491491- // -----------------------------------------------------------------------
492492- // I1c: Frame vectors are approximately orthonormal at level 0
493493- // -----------------------------------------------------------------------
494415495416 #[test]
496417 fn eval_coil_level0_frame_is_orthonormal() {
···512433 }
513434 }
514435515515- // -----------------------------------------------------------------------
516516- // I1d: eval_coil level-1 positions are offset from level-0 by ~child_radius
517517- // -----------------------------------------------------------------------
518436519437 #[test]
520438 fn eval_coil_level1_offset_from_level0_by_child_radius() {
···538456 }
539457 }
540458541541- // -----------------------------------------------------------------------
542542- // I1e: eval_coil level-1 produces finite positions
543543- // -----------------------------------------------------------------------
544459545460 #[test]
546461 fn eval_coil_level1_finite_for_various_t_values() {
+50-213
src/ingest.rs
···11use bevy::prelude::*;
22-use crossbeam_channel::Receiver;
22+use crossbeam_channel::{Receiver, bounded};
33use crossbeam_queue::ArrayQueue;
44use futures_util::StreamExt;
55-use jacquard::jetstream::JetstreamMessage;
55+use jacquard::DefaultStr;
66+use jacquard::jetstream::{JetstreamMessage, JetstreamParams};
67use jacquard::types::string::{Did, Nsid};
78use jacquard::xrpc::{BasicSubscriptionClient, SubscriptionClient};
89use std::sync::Arc;
1010+use std::sync::atomic::{AtomicBool, AtomicI64};
1111+use std::time::Duration;
9121013use crate::db::{DbChannel, DbEvent, DbReadRequest, DbWriteRequest};
1114use crate::helix::{self, HelixHierarchy, HelixState};
1215use crate::net::AsyncTungsteniteClient;
13161414-// ---------------------------------------------------------------------------
1515-// Jetstream → Bevy event pipeline
1616-// ---------------------------------------------------------------------------
1717+use jacquard::api::app_bsky::feed::like::LikeRecord;
1818+use jacquard::api::app_bsky::feed::post::PostRecord;
1919+use jacquard::api::app_bsky::graph::follow::FollowRecord;
2020+use jacquard::common::types::collection::Collection;
17211818-/// Type alias for events flowing from Jetstream → Bevy.
1922pub type IngestEvent = JetstreamMessage;
20232121-/// Lock-free bounded queue for Jetstream → Bevy communication.
2222-///
2323-/// Uses crossbeam's `ArrayQueue` with `force_push()` — when full, the oldest
2424-/// event is displaced. Truly lock-free (no mutex), both push and pop take `&self`.
2525-/// The producer runs at firehose speed on IoTaskPool; the consumer pops per frame.
2424+/// Lock-free ring buffer: producer force_push, consumer pops per frame.
2625#[derive(Resource)]
2726pub struct JetstreamChannels {
2827 pub queue: Arc<ArrayQueue<IngestEvent>>,
2928}
30293131-/// Shared mesh + material handles for the small dot rendered at each event position.
3232-/// Spawned once at startup; every event entity clones these handles (cheap, instanced).
3330#[derive(Resource)]
3431pub struct EventDotAssets {
3532 pub mesh: Handle<Mesh>,
···4037 pub default_bsky: Handle<StandardMaterial>,
4138}
42394343-/// Shared time state written by the consumer task, read by Bevy systems.
4444-/// Updated for EVERY event (not just sampled ones) so time tracking is accurate.
4540#[derive(Resource)]
4641pub struct SharedTimeState {
4747- pub anchor_us: Arc<std::sync::atomic::AtomicI64>,
4848- pub latest_us: Arc<std::sync::atomic::AtomicI64>,
4949- pub initialized: Arc<std::sync::atomic::AtomicBool>,
4242+ pub anchor_us: Arc<AtomicI64>,
4343+ pub latest_us: Arc<AtomicI64>,
4444+ pub initialized: Arc<AtomicBool>,
5045}
51465252-// ---------------------------------------------------------------------------
5353-// Task 5: Event Marker Component and Time Window Resource
5454-// ---------------------------------------------------------------------------
5555-5656-/// Component wrapping a Jetstream event. Preserves the full JetstreamMessage enum.
5757-/// Commit events carry Did, CommitOperation, Nsid, Rkey, Data, Cid.
5858-/// Identity events carry Did, Handle, seq, time.
5959-/// Account events carry Did, active, status, seq, time.
6060-/// All types are owned on bos-beta — no lifetime parameters.
6147#[derive(Component, Debug, Clone)]
6248pub struct JetstreamEventMarker(pub JetstreamMessage);
63496450impl JetstreamEventMarker {
6565- /// Extract the DID from any event variant.
6651 pub fn did(&self) -> &Did {
6752 match &self.0 {
6853 JetstreamMessage::Commit { did, .. } => did,
···7156 }
7257 }
73587474- /// Extract time_us from any event variant.
7559 pub fn time_us(&self) -> i64 {
7660 match &self.0 {
7761 JetstreamMessage::Commit { time_us, .. } => *time_us,
···8064 }
8165 }
82668383- /// Extract collection NSID (only for Commit events).
8467 pub fn collection(&self) -> Option<&Nsid> {
8568 match &self.0 {
8669 JetstreamMessage::Commit { commit, .. } => Some(&commit.collection),
···8871 }
8972 }
90739191- /// Extract the record Data (only for Commit events that carry a record).
9274 pub fn record(&self) -> Option<&jacquard::Data> {
9375 match &self.0 {
9476 JetstreamMessage::Commit { commit, .. } => commit.record.as_ref(),
···9779 }
9880}
9981100100-/// Marker: this event entity needs a particle effect spawned.
101101-///
102102-/// Attached to every newly spawned event entity by `drain_jetstream_events`.
103103-/// Consumed and removed by `spawn_event_particles` after the effect child
104104-/// is attached.
10582#[derive(Component)]
10683pub struct NeedsParticle;
10784108108-/// Whether this event came from the live Jetstream feed or historical SQLite load.
109109-///
110110-/// Determines which effect variant to use:
111111-/// - `Live` → bloom outward (particles radiate away from helix surface)
112112-/// - `Historical` → bloom inward (particles converge onto the helix surface)
8585+/// Live = bloom outward, Historical = bloom inward.
11386#[derive(Component, Debug, Clone, Copy, PartialEq)]
11487pub enum EventSource {
11588 Live,
11689 Historical,
11790}
11891119119-/// Maps absolute timestamps (microseconds) to unbounded helix t-space.
120120-///
121121-/// One unit of t = one turn of the outermost helix = MICROS_PER_TURN microseconds.
122122-/// `anchor_us` is set on first event. `t = (time_us - anchor_us) / MICROS_PER_TURN`.
123123-/// t is unbounded and grows continuously as time passes.
9292+/// `t = (time_us - anchor_us) / MICROS_PER_TURN`. 1 t-unit = 1 minute.
12493#[derive(Resource, Debug)]
12594pub struct TimeWindow {
12695 pub anchor_us: i64,
···12998}
13099131100impl TimeWindow {
132132- /// Microseconds per one unit of t (one outermost helix turn).
133133- /// 60 seconds = one turn = one minute of real time.
134101 pub const MICROS_PER_TURN: f64 = 60_000_000.0;
135102136103 pub fn time_to_t(&self, time_us: i64) -> f32 {
···138105 }
139106}
140107141141-// ---------------------------------------------------------------------------
142142-// Task 4 & 5: Historical event loading resources
143143-// ---------------------------------------------------------------------------
144144-145145-/// Tracks which microsecond time ranges have already been loaded from SQLite.
146146-///
147147-/// Stored as sorted, non-overlapping `(start_us, end_us)` intervals. Before
148148-/// triggering a new history query, callers check whether the desired range is
149149-/// already covered.
108108+/// Sorted, non-overlapping `(start_us, end_us)` intervals already loaded from SQLite.
150109#[derive(Resource, Debug, Default)]
151110pub struct LoadedRanges {
152152- /// Sorted list of `(start_us, end_us)` intervals that have been loaded.
153111 pub ranges: Vec<(i64, i64)>,
154112}
155113156114impl LoadedRanges {
157157- /// Returns `true` if the given range is fully covered by loaded intervals.
158115 pub fn is_covered(&self, start_us: i64, end_us: i64) -> bool {
159116 self.ranges
160117 .iter()
161118 .any(|(lo, hi)| *lo <= start_us && *hi >= end_us)
162119 }
163120164164- /// Add a new range, merging with any overlapping/adjacent intervals.
165121 pub fn insert(&mut self, start_us: i64, end_us: i64) {
166122 self.ranges.push((start_us, end_us));
167123 self.ranges.sort_unstable_by_key(|r| r.0);
168124169169- // Merge overlapping / adjacent intervals.
170125 let mut merged: Vec<(i64, i64)> = Vec::with_capacity(self.ranges.len());
171126 for (lo, hi) in self.ranges.drain(..) {
172127 if let Some(last) = merged.last_mut() {
173173- // Overlap or adjacent (within 1 µs) → extend.
174128 if lo <= last.1 + 1 {
175129 last.1 = last.1.max(hi);
176130 continue;
···182136 }
183137}
184138185185-/// State for in-flight history queries triggered by panning.
186186-///
187187-/// Holds the response receiver for the most recently sent `QueryTimeRange`
188188-/// request so that `poll_history_responses` can drain it each frame. Also
189189-/// records the queried range so it can be marked as loaded on receipt.
190139#[derive(Resource, Default)]
191140pub struct HistoryQueryState {
192192- /// Response channel for the current in-flight query, if any.
193141 pub pending: Option<Receiver<Vec<Vec<rusqlite::types::Value>>>>,
194194- /// The `(start_us, end_us)` range of the pending query (used to update
195195- /// `LoadedRanges` once the response arrives).
196142 pub pending_range: Option<(i64, i64)>,
197143}
198144199199-/// Sampling rate for high-volume event types (posts, likes).
200200-/// Only 1-in-N events are forwarded to the Bevy ring buffer.
201201-/// Everything goes to SQLite regardless.
202145const SAMPLE_RATE: u64 = 10;
203203-204204-/// Ring buffer capacity. When full, oldest events are silently overwritten
205205-/// by the producer — no backpressure, no blocking, no cascading drops.
206146const RING_BUFFER_CAPACITY: usize = 1024;
207147208208-/// Spawn the Jetstream consumer task on IoTaskPool.
209209-///
210210-/// The consumer:
211211-/// 1. Queries SQLite for the latest cached cursor
212212-/// 2. Connects to Jetstream and subscribes
213213-/// 3. For EVERY event: updates time tracking atomics + batches to SQLite
214214-/// 4. For SAMPLED events only: writes to the ring buffer for Bevy to consume
215215-/// 5. Reconnects on error with cursor
216216-///
217217-fn emissive_mat(materials: &mut Assets<StandardMaterial>, base: Color, emissive: LinearRgba) -> Handle<StandardMaterial> {
148148+fn emissive_mat(
149149+ materials: &mut Assets<StandardMaterial>,
150150+ base: Color,
151151+ emissive: LinearRgba,
152152+) -> Handle<StandardMaterial> {
218153 materials.add(StandardMaterial {
219154 base_color: base,
220155 emissive,
···222157 })
223158}
224159225225-/// Create the shared mesh + materials for event dot markers.
226226-/// Emissive materials give dots a self-illuminated glow against the black background.
227160pub fn setup_event_dots(
228161 mut commands: Commands,
229162 mut meshes: ResMut<Assets<Mesh>>,
···232165 let mesh = meshes.add(Sphere::new(0.002).mesh().ico(2).unwrap());
233166 commands.insert_resource(EventDotAssets {
234167 mesh,
235235- post: emissive_mat(&mut materials,
168168+ post: emissive_mat(
169169+ &mut materials,
236170 Color::srgb(0.08, 0.15, 0.4),
237237- LinearRgba::new(0.2, 0.4, 1.5, 1.0)),
238238- like: emissive_mat(&mut materials,
171171+ LinearRgba::new(0.2, 0.4, 1.5, 1.0),
172172+ ),
173173+ like: emissive_mat(
174174+ &mut materials,
239175 Color::srgb(0.4, 0.08, 0.25),
240240- LinearRgba::new(1.5, 0.2, 0.8, 1.0)),
241241- follow: emissive_mat(&mut materials,
176176+ LinearRgba::new(1.5, 0.2, 0.8, 1.0),
177177+ ),
178178+ follow: emissive_mat(
179179+ &mut materials,
242180 Color::srgb(0.08, 0.4, 0.2),
243243- LinearRgba::new(0.2, 1.5, 0.4, 1.0)),
244244- shiny: emissive_mat(&mut materials,
181181+ LinearRgba::new(0.2, 1.5, 0.4, 1.0),
182182+ ),
183183+ shiny: emissive_mat(
184184+ &mut materials,
245185 Color::srgb(0.4, 0.35, 0.08),
246246- LinearRgba::new(1.5, 1.2, 0.2, 1.0)),
247247- default_bsky: emissive_mat(&mut materials,
186186+ LinearRgba::new(1.5, 1.2, 0.2, 1.0),
187187+ ),
188188+ default_bsky: emissive_mat(
189189+ &mut materials,
248190 Color::srgb(0.15, 0.15, 0.2),
249249- LinearRgba::new(0.4, 0.4, 0.5, 1.0)),
191191+ LinearRgba::new(0.4, 0.4, 0.5, 1.0),
192192+ ),
250193 });
251194}
252195253253-/// Time tracking uses atomics so the main thread always has accurate time
254254-/// state even when it can't keep up with the full event rate.
255196pub fn spawn_jetstream_consumer(mut commands: Commands, db_channel: Res<DbChannel>) {
256197 use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
257198···283224284225 // Query latest cursor from SQLite for reconnection.
285226 let initial_cursor = {
286286- let (resp_tx, resp_rx) = crossbeam_channel::bounded::<Option<i64>>(1);
227227+ let (resp_tx, resp_rx) = bounded::<Option<i64>>(1);
287228 if db_reader
288229 .send(DbReadRequest::GetLatestCursor {
289230 response_tx: resp_tx,
···317258318259 let sub_client = BasicSubscriptionClient::new(client, base_uri);
319260320320- let params = jacquard::jetstream::JetstreamParams::<jacquard::DefaultStr>::new()
261261+ let params = JetstreamParams::<DefaultStr>::new()
321262 .wanted_collections(vec![])
322263 .compress(false)
323264 .build();
···349290 };
350291 cursor = Some(time_us);
351292352352- // Update shared time state atomically — always, for every event.
353293 if !init_handle.load(Ordering::Relaxed) {
354294 anchor_handle.store(time_us, Ordering::Relaxed);
355295 init_handle.store(true, Ordering::Release);
356296 }
357297 latest_handle.fetch_max(time_us, Ordering::Relaxed);
358298359359- // SQLite: batch ALL events.
360299 if let Some(db_event) = jetstream_to_db_event(&msg) {
361300 db_batch.push(db_event);
362301 }
363302364364- // Ring buffer: sample high-volume events.
365303 let is_high_volume = match &msg {
366304 JetstreamMessage::Commit { commit, .. } => {
367367- use jacquard::api::app_bsky::feed::like::LikeRecord;
368368- use jacquard::api::app_bsky::feed::post::PostRecord;
369369- use jacquard::common::types::collection::Collection;
370305 let col: &str = commit.collection.as_ref();
371306 col == <PostRecord as Collection>::NSID
372307 || col == <LikeRecord as Collection>::NSID
···385320 queue_handle.force_push(msg);
386321 }
387322388388- // Flush DB batch on size or time threshold.
389323 if db_batch.len() >= 100
390390- || last_flush.elapsed()
391391- >= std::time::Duration::from_millis(500)
324324+ || last_flush.elapsed() >= Duration::from_millis(500)
392325 {
393326 flush_db_batch(&db_writer, &mut db_batch);
394327 last_flush = std::time::Instant::now();
···407340 }
408341 }
409342410410- futures_timer::Delay::new(std::time::Duration::from_secs(2)).await;
343343+ futures_timer::Delay::new(Duration::from_secs(2)).await;
411344 }
412345 })
413346 .detach();
414347}
415348416416-// ---------------------------------------------------------------------------
417417-// SQLite batch helpers
418418-// ---------------------------------------------------------------------------
419419-420420-/// Flush the accumulated batch to the SQLite worker and clear the buffer.
421421-///
422422-/// If the channel is full or closed, events are dropped with a warning rather
423423-/// than blocking the async consumer task.
424349fn flush_db_batch(db_writer: &crossbeam_channel::Sender<DbWriteRequest>, batch: &mut Vec<DbEvent>) {
425350 if batch.is_empty() {
426351 return;
···434359 }
435360}
436361437437-/// Convert a `JetstreamMessage` into a `DbEvent` for SQLite persistence.
438438-///
439439-/// The entire message is serialized as JSON into `message_json` for a lossless
440440-/// round-trip. Indexed columns (`kind`, `did`, `time_us`, `collection`) are
441441-/// extracted separately so SQLite can filter efficiently without parsing JSON.
442362fn jetstream_to_db_event(msg: &JetstreamMessage) -> Option<DbEvent> {
443363 let message_json = match serde_json::to_string(msg) {
444364 Ok(json) => json,
···476396 })
477397}
478398479479-/// Drain pre-filtered events from the ring buffer and spawn ECS entities.
480480-///
481481-/// The producer task (IoTaskPool) already sampled high-volume events and
482482-/// updated SharedTimeState atomics. This system just pops whatever's
483483-/// available and spawns entities — no filtering, no time bookkeeping.
484484-/// If a frame hitches, the ring buffer overwrites old events silently.
485399pub fn drain_jetstream_events(
486400 mut commands: Commands,
487401 channels: Res<JetstreamChannels>,
···534448535449 let dot_mat = match &event {
536450 JetstreamMessage::Commit { commit, .. } => {
537537- use jacquard::api::app_bsky::feed::like::LikeRecord;
538538- use jacquard::api::app_bsky::feed::post::PostRecord;
539539- use jacquard::api::app_bsky::graph::follow::FollowRecord;
540540- use jacquard::common::types::collection::Collection;
541451 let col: &str = commit.collection.as_ref();
542452 if col == <PostRecord as Collection>::NSID {
543453 dot_assets.post.clone()
···567477 }
568478}
569479570570-// ---------------------------------------------------------------------------
571571-// Task 4: Load historical events on startup
572572-// ---------------------------------------------------------------------------
573573-574480/// Startup system: query SQLite for recent events and spawn them as historical entities.
575575-///
576576-/// This runs after `spawn_db_thread` and `spawn_jetstream_consumer`. It uses a
577577-/// blocking `recv()` which is acceptable at startup (the DB worker responds
578578-/// quickly to the MAX query). Events are placed on the helix with
579579-/// `EventSource::Historical` so the particle system shows reverse-bloom effects.
580580-///
581581-/// The query window: up to 1 hour before the most recent persisted event.
582481pub fn load_historical_events(
583482 mut commands: Commands,
584483 db_channel: Res<DbChannel>,
···587486 mut time_window: ResMut<TimeWindow>,
588487 mut loaded_ranges: ResMut<LoadedRanges>,
589488) {
590590- // Step 1: get the latest cursor so we know where in time we are.
591489 let latest_us = {
592592- let (resp_tx, resp_rx) = crossbeam_channel::bounded::<Option<i64>>(1);
490490+ let (resp_tx, resp_rx) = bounded::<Option<i64>>(1);
593491 if db_channel
594492 .reader
595493 .send(DbReadRequest::GetLatestCursor {
···614512 return;
615513 };
616514617617- // Step 2: compute the query window: last 1 hour = 3600 seconds.
618618- const HISTORY_WINDOW_US: i64 = 60_000_000; // 1 minute in microseconds
515515+ const HISTORY_WINDOW_US: i64 = 60_000_000;
619516 let start_us = end_us - HISTORY_WINDOW_US;
620517621518 info!(
···623520 start_us, end_us
624521 );
625522626626- // Step 3: query the time range.
627627- let (resp_tx, resp_rx) = crossbeam_channel::bounded::<Vec<Vec<rusqlite::types::Value>>>(1);
523523+ let (resp_tx, resp_rx) = bounded::<Vec<Vec<rusqlite::types::Value>>>(1);
628524 if db_channel
629525 .reader
630526 .send(DbReadRequest::QueryTimeRange {
···651547 rows.len()
652548 );
653549654654- // Step 4: initialize TimeWindow from historical data if not yet set.
655550 if !time_window.is_initialized
656551 && let Some(first_row) = rows.first()
657552 && let Some(t) = row_time_us(first_row)
···664559 );
665560 }
666561667667- // Step 5: spawn entities for all historical rows.
668562 let count =
669563 spawn_historical_entities(&mut commands, &rows, &hierarchy, &state, &mut time_window);
670564···673567 count
674568 );
675569676676- // Step 6: record this range as loaded so pan detection doesn't re-request it.
677570 loaded_ranges.insert(start_us, end_us);
678571}
679572680680-// ---------------------------------------------------------------------------
681681-// Task 5: Poll for pan-triggered history query responses
682682-// ---------------------------------------------------------------------------
683683-684684-/// Update system: drain any pending history query responses and spawn entities.
685685-///
686686-/// Each frame, checks if a pan-triggered `QueryTimeRange` has returned results.
687687-/// Spawns all historical entities from the response and marks the range as loaded.
688573pub fn poll_history_responses(
689574 mut commands: Commands,
690575 mut query_state: ResMut<HistoryQueryState>,
···711596 count
712597 );
713598714714- // Mark the queried range as loaded so we don't re-request it.
715599 if let Some((start_us, end_us)) = query_state.pending_range.take() {
716600 loaded_ranges.insert(start_us, end_us);
717601 }
718602719719- // Clear the pending receiver — the query is complete.
720603 query_state.pending = None;
721604 }
722722- Err(crossbeam_channel::TryRecvError::Empty) => {
723723- // Still waiting; nothing to do this frame.
724724- }
605605+ Err(crossbeam_channel::TryRecvError::Empty) => {}
606606+725607 Err(crossbeam_channel::TryRecvError::Disconnected) => {
726608 warn!("poll_history_responses: SQLite worker closed response channel");
727609 query_state.pending = None;
···730612 }
731613}
732614733733-// ---------------------------------------------------------------------------
734734-// Shared helpers
735735-// ---------------------------------------------------------------------------
736736-737737-/// Extract `time_us` from a DB row.
738738-///
739739-/// The `time_us` column is index 3 (0-indexed: id=0, kind=1, did=2, time_us=3).
740615pub fn row_time_us(row: &[rusqlite::types::Value]) -> Option<i64> {
741616 match row.get(3)? {
742617 rusqlite::types::Value::Integer(v) => Some(*v),
···744619 }
745620}
746621747747-/// Extract a text value from a DB row at the given column index.
748622fn row_text(row: &[rusqlite::types::Value], idx: usize) -> Option<&str> {
749623 match row.get(idx)? {
750624 rusqlite::types::Value::Text(s) => Some(s.as_str()),
···752626 }
753627}
754628755755-/// Convert a DB row into a `JetstreamMessage`.
756756-///
757757-/// Columns: id(0), kind(1), did(2), time_us(3), collection(4), message_json(5).
758758-///
759759-/// Deserializes the full `JetstreamMessage` from `message_json` — lossless
760760-/// round-trip with no fabricated placeholder values.
761629fn row_to_message(row: &[rusqlite::types::Value]) -> Option<JetstreamMessage> {
762630 let json = row_text(row, 5)?;
763631 match serde_json::from_str::<JetstreamMessage>(json) {
···769637 }
770638}
771639772772-/// Spawn ECS entities for a batch of DB rows.
773773-///
774774-/// Returns the number of entities successfully spawned. Rows that cannot be
775775-/// converted (missing required fields, unknown kind, etc.) are skipped with
776776-/// a warning rather than aborting the batch.
777777-///
778778-/// Each entity receives:
779779-/// - `JetstreamEventMarker` with the reconstructed message
780780-/// - `Transform` at the helix position for the event's `time_us`
781781-/// - `Visibility::default()` (required for particle hierarchy)
782782-/// - NO `NeedsParticle` — historical events skip particles on load (perf)
783783-/// - `EventSource::Historical`
784640fn spawn_historical_entities(
785641 commands: &mut Commands,
786642 rows: &[Vec<rusqlite::types::Value>],
···800656 continue;
801657 };
802658803803- // Lazily initialize the TimeWindow if this is the first event we process.
804659 if !time_window.is_initialized {
805660 time_window.anchor_us = time_us;
806661 time_window.is_initialized = true;
···811666812667 let frame = helix::eval_coil(t, hierarchy.levels.len() - 1, hierarchy);
813668814814- // Historical events skip NeedsParticle — no point blooming particles
815815- // for events already in the past. Particles can be added later if the
816816- // user pans back to inspect this region.
817669 commands.spawn((
818670 JetstreamEventMarker(msg),
819671 Transform::from_translation(frame.position),
···827679 count
828680}
829681830830-// ---------------------------------------------------------------------------
831831-// Tests
832832-// ---------------------------------------------------------------------------
833833-834682#[cfg(test)]
835683mod tests {
836684 use super::*;
···840688 use jacquard::types::string::{Cid, Datetime, Did, Nsid, Rkey};
841689 use std::str::FromStr;
842690843843- // -----------------------------------------------------------------------
844844- // LoadedRanges::is_covered
845845- // -----------------------------------------------------------------------
846691847692 #[test]
848693 fn is_covered_empty_ranges_returns_false() {
···861706 fn is_covered_subset_of_loaded_range() {
862707 let mut ranges = LoadedRanges::default();
863708 ranges.insert(0, 1000);
864864- // Any sub-range that fits inside should be covered.
865709 assert!(ranges.is_covered(100, 200));
866710 assert!(ranges.is_covered(0, 1000));
867711 assert!(ranges.is_covered(500, 999));
···871715 fn is_covered_partial_overlap_returns_false() {
872716 let mut ranges = LoadedRanges::default();
873717 ranges.insert(100, 200);
874874- // Extends beyond the loaded range on either side.
875718 assert!(!ranges.is_covered(50, 200));
876719 assert!(!ranges.is_covered(100, 250));
877720 assert!(!ranges.is_covered(50, 250));
···885728 assert!(!ranges.is_covered(0, 50));
886729 }
887730888888- // -----------------------------------------------------------------------
889889- // LoadedRanges::insert
890890- // -----------------------------------------------------------------------
891731892732 #[test]
893733 fn insert_single_range() {
···942782 assert_eq!(ranges.ranges, vec![(100, 600)]);
943783 }
944784945945- // -----------------------------------------------------------------------
946946- // Serialization roundtrip: JetstreamMessage
947947- // -----------------------------------------------------------------------
948785949786 fn make_commit_message() -> JetstreamMessage {
950787 let did = Did::new_static("did:plc:testuser123456789").expect("valid DID");
+1-5
src/interaction.rs
···198198 }
199199}
200200201201-/// Pan past loaded range → trigger SQLite query (5 min either side of focal_time).
201201+/// Pan past loaded range -> trigger SQLite query (5 min either side of focal_time).
202202pub fn check_pan_triggers_history_load(
203203 state: Res<HelixState>,
204204 time_window: Res<TimeWindow>,
···251251 query_state.pending = Some(resp_rx);
252252 query_state.pending_range = Some((query_start, query_end));
253253}
254254-255255-// ---------------------------------------------------------------------------
256256-// Event picking
257257-// ---------------------------------------------------------------------------
258254259255#[derive(Resource, Default)]
260256pub struct SelectedEvent {
···11-use async_tungstenite::async_tls::client_async_tls;
22-use async_tungstenite::tungstenite;
11+use std::sync::Arc;
22+33+use async_tungstenite::tungstenite::protocol::frame;
44+use async_tungstenite::tungstenite::{self, Utf8Bytes};
55+use async_tungstenite::{async_tls::client_async_tls, tungstenite::Message};
36use bytes::Bytes;
47use futures_util::{SinkExt, StreamExt};
58use jacquard::{
69 CloseCode, CloseFrame, StreamError, WebSocketClient, WebSocketConnection, WsMessage, WsSink,
77- WsStream, WsText,
1010+ WsStream, WsText, client::BasicClient,
811};
912use smol::net::TcpStream;
1010-1111-// ---------------------------------------------------------------------------
1212-// Task 2: WebSocketClient using async-tungstenite + async-tls (rustls)
1313-// ---------------------------------------------------------------------------
14131514#[derive(Debug, Clone, Default)]
1615pub struct AsyncTungsteniteClient;
···6463 }
6564}
66656767-fn convert_to_ws_message(msg: tungstenite::Message) -> Option<WsMessage> {
6666+fn convert_to_ws_message(msg: Message) -> Option<WsMessage> {
6867 match msg {
6969- tungstenite::Message::Text(utf8_bytes) => {
6868+ Message::Text(utf8_bytes) => {
7069 // Both Utf8Bytes and WsText wrap Bytes with UTF-8 invariant — zero-copy
7170 let bytes: Bytes = utf8_bytes.into();
7271 // Safety: tungstenite already validated UTF-8
···7473 WsText::from_bytes_unchecked(bytes)
7574 }))
7675 }
7777- tungstenite::Message::Binary(data) => Some(WsMessage::Binary(data)),
7878- tungstenite::Message::Close(frame) => {
7676+ Message::Binary(data) => Some(WsMessage::Binary(data)),
7777+ Message::Close(frame) => {
7978 let close_frame = frame.map(|f| {
8079 let code = convert_close_code(f.code);
8180 let reason_bytes: Bytes = f.reason.into();
···8988 }
9089}
91909292-fn convert_from_ws_message(msg: WsMessage) -> tungstenite::Message {
9393- use tungstenite::protocol::frame::Utf8Bytes;
9191+fn convert_from_ws_message(msg: WsMessage) -> Message {
9492 match msg {
9593 WsMessage::Text(text) => {
9694 // WsText → Bytes → Utf8Bytes, both already UTF-8 validated
9795 let bytes = text.into_bytes();
9896 // Safety: WsText guarantees UTF-8
9999- tungstenite::Message::Text(unsafe { Utf8Bytes::from_bytes_unchecked(bytes) })
9797+ Message::Text(unsafe { Utf8Bytes::from_bytes_unchecked(bytes) })
10098 }
101101- WsMessage::Binary(bytes) => tungstenite::Message::Binary(bytes),
9999+ WsMessage::Binary(bytes) => Message::Binary(bytes),
102100 WsMessage::Close(frame) => {
103101 let close_frame = frame.map(|f| {
104102 let code: u16 = f.code.into();
105105- tungstenite::protocol::frame::CloseFrame {
103103+ frame::CloseFrame {
106104 code: code.into(),
107105 reason: Utf8Bytes::from(f.reason.to_string()),
108106 }
109107 });
110110- tungstenite::Message::Close(close_frame)
108108+ Message::Close(close_frame)
111109 }
112110 }
113111}
114112115115-fn convert_close_code(code: tungstenite::protocol::frame::coding::CloseCode) -> CloseCode {
116116- use tungstenite::protocol::frame::coding::CloseCode as TC;
113113+fn convert_close_code(code: frame::coding::CloseCode) -> CloseCode {
114114+ use frame::coding::CloseCode as TC;
117115 match code {
118116 TC::Normal => CloseCode::Normal,
119117 TC::Away => CloseCode::Away,
···132130 }
133131}
134132135135-// ---------------------------------------------------------------------------
136136-// Task 3: reqwest::Client as a shared Bevy resource
137137-//
138138-// With bevy-tokio-tasks, we have a real tokio runtime. reqwest::Client runs
139139-// natively on tokio — no DNS issues, no runtime conflicts. jacquard provides
140140-// `impl HttpClient for reqwest::Client` via the `reqwest-client` feature.
141141-// ---------------------------------------------------------------------------
142142-143133use bevy::prelude::*;
144134145135/// Shared AT Protocol client for XRPC calls.
146146-/// Uses jacquard's BasicClient — unauthenticated, reqwest-backed.
147147-/// Wrapped in Arc for sharing with tokio background tasks.
148136#[derive(Resource, Clone)]
149149-pub struct AtpClient(pub std::sync::Arc<jacquard::client::BasicClient>);
137137+pub struct AtpClient(pub Arc<BasicClient>);
150138151139pub fn setup_atp_client(mut commands: Commands) {
152152- commands.insert_resource(AtpClient(std::sync::Arc::new(
153153- jacquard::client::BasicClient::unauthenticated(),
154154- )));
140140+ commands.insert_resource(AtpClient(Arc::new(BasicClient::unauthenticated())));
155141}
+25-193
src/particles.rs
···11use bevy::prelude::*;
22use bevy::image::{Image, TextureFormatPixelInfo};
33use bevy_hanabi::prelude::*;
44-// Use the hanabi Gradient explicitly to avoid ambiguity with bevy's UI Gradient
54use bevy_hanabi::Gradient as HanabiGradient;
65use jacquard::common::types::collection::Collection;
7687use crate::helix::HelixState;
98use crate::ingest::{EventSource, JetstreamEventMarker, NeedsParticle, TimeWindow};
1091111-/// Which zoom variant to use when selecting a particle effect.
1210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1311pub enum ZoomVariant {
1414- /// Helix levels 0–1 (hours, minutes): shorter lifetime, smaller size.
1512 Outer,
1616- /// Helix levels 2–3 (seconds, sub-second): longer lifetime, larger size.
1713 Inner,
1814}
19152016impl ZoomVariant {
2121- /// Classify the current helix active level into a zoom variant.
2217 pub fn from_level(level: usize) -> Self {
2323- if level <= 1 {
2424- ZoomVariant::Outer
2525- } else {
2626- ZoomVariant::Inner
2727- }
1818+ if level <= 1 { ZoomVariant::Outer } else { ZoomVariant::Inner }
2819 }
2920}
30213131-/// Per-zoom-variant live/historical effect handle pair.
3222#[derive(Clone)]
3323pub struct ZoomEffects {
3424 pub live: Handle<EffectAsset>,
3525 pub hist: Handle<EffectAsset>,
3626}
37273838-/// Outer (dense, short-lived) and inner (spread, long-lived) zoom variants for fallback effects.
3928#[derive(Clone)]
4029pub struct ZoomPair {
4130 pub outer: ZoomEffects,
···4332}
44334534impl ZoomPair {
4646- /// Select the appropriate ZoomEffects variant for the given zoom level.
4735 pub fn select(&self, zoom: ZoomVariant) -> &ZoomEffects {
4836 match zoom {
4937 ZoomVariant::Outer => &self.outer,
···5240 }
5341}
54425555-/// Particle effects keyed by NSID or NSID prefix. Longest prefix match wins.
4343+/// NSID-keyed particle effects. Longest prefix match wins.
5644#[derive(Resource)]
5745pub struct ParticleEffects {
5858- /// Registered effects: (nsid_or_prefix, outer_variant, inner_variant).
5959- /// Sorted by key length descending for longest-prefix matching.
6046 pub effects: Vec<(&'static str, ZoomEffects, ZoomEffects)>,
6161- /// Fallback for unmatched app.bsky.* collections (other Bluesky event types).
6262- /// Contains both outer and inner zoom variants.
6347 pub bsky_default: ZoomPair,
6464- /// Distinctive "shiny" effect for non-Bluesky AT Protocol events
6565- /// (Identity/Account events, etc. with no collection NSID).
6648 pub shiny: ZoomPair,
6749}
68506951impl ParticleEffects {
7070- /// Look up the effect pair for the given NSID and zoom variant.
7152 pub fn lookup(&self, collection_str: Option<&str>, zoom: ZoomVariant) -> &ZoomEffects {
7253 collection_str
7354 .and_then(|collection| {
···8667 }
8768}
88698989-/// Bloom effect parameters for a single speed/lifetime/size/burst combination.
9070#[derive(Clone, Copy)]
9171pub struct BloomParams {
9292- /// Radial speed: positive = outward (live), negative = inward (historical).
9372 pub speed: f32,
9494- /// Particle lifetime in seconds.
9573 pub lifetime: f32,
9696- /// Initial particle size.
9774 pub size: f32,
9898- /// Number of particles in the one-shot burst.
9975 pub burst_count: f32,
10076}
10177102102-/// Generate a soft radial gradient circle texture for particles.
103103-/// White center fading to transparent edges — gaussian-ish falloff.
7878+/// Soft radial gradient circle texture (gaussian-ish falloff).
10479pub fn generate_soft_circle(images: &mut Assets<Image>) -> Handle<Image> {
10580 const SIZE: u32 = 32;
10681 let mut data = vec![0u8; (SIZE * SIZE * 4) as usize];
···11085 let dx = x as f32 + 0.5 - center;
11186 let dy = y as f32 + 0.5 - center;
11287 let dist = (dx * dx + dy * dy).sqrt() / center;
113113- // Smooth gaussian-ish falloff
11488 let alpha = (1.0 - dist * dist).max(0.0).powf(2.0);
11589 let i = ((y * SIZE + x) * 4) as usize;
116116- data[i] = 255; // R
117117- data[i + 1] = 255; // G
118118- data[i + 2] = 255; // B
119119- data[i + 3] = (alpha * 255.0) as u8; // A
9090+ data[i] = 255;
9191+ data[i + 1] = 255;
9292+ data[i + 2] = 255;
9393+ data[i + 3] = (alpha * 255.0) as u8;
12094 }
12195 }
12296 images.add(Image::new(
···132106 ))
133107}
134108135135-/// Create a particle effect: soft round glowing particles.
136136-///
137137-/// `speed` is radial velocity: positive = outward (live), negative = inward (historical).
138138-/// Near-zero speed = particles just appear and fade in place.
139109pub fn create_bloom_effect(gradient: HanabiGradient<Vec4>, params: BloomParams) -> EffectAsset {
140110 let writer = ExprWriter::new();
141111142142- // Spawn particles within a tiny sphere
143112 let init_pos = SetPositionSphereModifier {
144113 center: writer.lit(Vec3::ZERO).expr(),
145114 radius: writer.lit(0.005).expr(),
146115 dimension: ShapeDimension::Volume,
147116 };
148117149149- // Very slow drift velocity
150118 let init_vel = SetVelocitySphereModifier {
151119 center: writer.lit(Vec3::ZERO).expr(),
152120 speed: writer.lit(params.speed).expr(),
···158126159127 let spawner = SpawnerSettings::once(params.burst_count.into());
160128161161- // Size gradient: full size → 0 as particle ages
162129 let mut size_gradient = HanabiGradient::new();
163130 size_gradient.add_key(0.0, Vec3::splat(params.size));
164131 size_gradient.add_key(0.8, Vec3::splat(params.size * 0.5));
165132 size_gradient.add_key(1.0, Vec3::ZERO);
166133167167- // Texture slot must be registered on the module before building the effect
168134 let slot_zero = writer.lit(0u32).expr();
169135 let mut module = writer.finish();
170136 module.add_texture_slot("particle");
···189155 })
190156}
191157192192-/// Build a two-key gradient: full-color at t=0, transparent at t=1.
193193-/// Values can exceed 1.0 for HDR bloom effect.
194158fn fade_gradient(r: f32, g: f32, b: f32) -> HanabiGradient<Vec4> {
195159 let mut gradient = HanabiGradient::new();
196160 gradient.add_key(0.0, Vec4::new(r, g, b, 1.0));
···199163 gradient
200164}
201165202202-/// Build live+historical `ZoomEffects` for a single color and zoom variant.
203203-///
204204-/// `outward_speed` must be positive; the historical effect uses its negation
205205-/// so particles converge inward (settling onto the helix surface).
206166fn make_zoom_effects(
207167 effects: &mut Assets<EffectAsset>,
208168 gradient: HanabiGradient<Vec4>,
···211171 size: f32,
212172 burst_count: f32,
213173) -> ZoomEffects {
214214- let live_params = BloomParams {
215215- speed: outward_speed,
216216- lifetime,
217217- size,
218218- burst_count,
219219- };
220220- let hist_params = BloomParams {
221221- speed: -outward_speed,
222222- lifetime,
223223- size,
224224- burst_count,
225225- };
174174+ let live_params = BloomParams { speed: outward_speed, lifetime, size, burst_count };
175175+ let hist_params = BloomParams { speed: -outward_speed, lifetime, size, burst_count };
226176 ZoomEffects {
227177 live: effects.add(create_bloom_effect(gradient.clone(), live_params)),
228178 hist: effects.add(create_bloom_effect(gradient, hist_params)),
229179 }
230180}
231181232232-/// Build the default NSID-keyed particle effects and return the resource.
233233-///
234234-/// Effect color scheme:
235235-/// - Posts: bright blue (HDR boosted for bloom)
236236-/// - Likes: hot pink
237237-/// - Follows: green
238238-/// - bsky_default: dim white (other Bluesky collections)
239239-/// - shiny: gold with larger burst and longer lifetime (non-Bluesky events)
240240-///
241241-/// Zoom variants:
242242-/// - Outer (level 0–1: hours/minutes): shorter lifetime (0.3 s), smaller size
243243-/// (0.025), slightly higher burst count to create a dense glow cloud
244244-/// - Inner (level 2–3: seconds/sub-second): longer lifetime (0.8 s), larger
245245-/// size (0.06), standard burst for individual event visibility
246182pub fn register_default_effects(effects: &mut Assets<EffectAsset>) -> ParticleEffects {
247247- // Soft round particles: slow drift, visible halos
248248- // speed, lifetime, size, burst_count
249249- // Soft round particles: slow drift, visible halos
250250- // speed, lifetime, size, burst_count
251251- // ---- Posts: blue glow ----
252183 let post_outer = make_zoom_effects(effects, fade_gradient(0.4, 0.8, 3.0), 0.0, 0.8, 0.008, 3.0);
253184 let post_inner = make_zoom_effects(effects, fade_gradient(0.4, 0.8, 3.0), 0.0, 1.2, 0.02, 4.0);
254185255255- // ---- Likes: pink glow ----
256186 let like_outer = make_zoom_effects(effects, fade_gradient(3.0, 0.4, 1.5), 0.0, 0.8, 0.006, 3.0);
257187 let like_inner = make_zoom_effects(effects, fade_gradient(3.0, 0.4, 1.5), 0.0, 1.2, 0.015, 4.0);
258188259259- // ---- Follows: teal glow ----
260260- let follow_outer =
261261- make_zoom_effects(effects, fade_gradient(0.3, 3.0, 1.0), 0.0, 0.8, 0.008, 4.0);
262262- let follow_inner =
263263- make_zoom_effects(effects, fade_gradient(0.3, 3.0, 1.0), 0.0, 1.2, 0.02, 5.0);
189189+ let follow_outer = make_zoom_effects(effects, fade_gradient(0.3, 3.0, 1.0), 0.0, 0.8, 0.008, 4.0);
190190+ let follow_inner = make_zoom_effects(effects, fade_gradient(0.3, 3.0, 1.0), 0.0, 1.2, 0.02, 5.0);
264191265265- // ---- bsky default: cool dim ----
266192 let bsky_default = ZoomPair {
267193 outer: make_zoom_effects(effects, fade_gradient(1.2, 1.2, 1.5), 0.0, 0.6, 0.005, 2.0),
268194 inner: make_zoom_effects(effects, fade_gradient(1.2, 1.2, 1.5), 0.0, 1.0, 0.012, 3.0),
269195 };
270196271271- // ---- Shiny: amber (non-Bluesky AT Protocol events) ----
272197 let mut gold_gradient = HanabiGradient::new();
273198 gold_gradient.add_key(0.0, Vec4::new(2.0, 1.5, 0.2, 0.7));
274199 gold_gradient.add_key(0.5, Vec4::new(1.5, 1.0, 0.1, 0.4));
···279204 inner: make_zoom_effects(effects, gold_gradient, 0.0, 1.5, 0.025, 5.0),
280205 };
281206282282- // Build the keyed lookup vec using Collection::NSID for type safety.
283283- // Static str keys avoid any Nsid<S> generic complexity.
284207 let mut keyed: Vec<(&'static str, ZoomEffects, ZoomEffects)> = vec![
285285- (
286286- jacquard::api::app_bsky::feed::post::PostRecord::NSID,
287287- post_outer,
288288- post_inner,
289289- ),
290290- (
291291- jacquard::api::app_bsky::feed::like::LikeRecord::NSID,
292292- like_outer,
293293- like_inner,
294294- ),
295295- (
296296- jacquard::api::app_bsky::graph::follow::FollowRecord::NSID,
297297- follow_outer,
298298- follow_inner,
299299- ),
208208+ (jacquard::api::app_bsky::feed::post::PostRecord::NSID, post_outer, post_inner),
209209+ (jacquard::api::app_bsky::feed::like::LikeRecord::NSID, like_outer, like_inner),
210210+ (jacquard::api::app_bsky::graph::follow::FollowRecord::NSID, follow_outer, follow_inner),
300211 ];
301301-302302- // Sort by key string length descending so the first match in a scan is
303303- // always the longest (most specific) prefix match.
304212 keyed.sort_by_key(|item| std::cmp::Reverse(item.0.len()));
305213306306- ParticleEffects {
307307- effects: keyed,
308308- bsky_default,
309309- shiny,
310310- }
214214+ ParticleEffects { effects: keyed, bsky_default, shiny }
311215}
312216313313-/// Soft circle texture handle for particle rendering.
314217#[derive(Resource)]
315218pub struct ParticleTexture(pub Handle<Image>);
316219317317-/// Startup system: build all default particle effects and register the resource.
318220pub fn setup_particle_effects(
319221 mut commands: Commands,
320222 mut effects: ResMut<Assets<EffectAsset>>,
···361263 }
362264}
363265364364-/// Resource tracking the current active zoom variant for particle selection.
365266#[derive(Resource, Debug)]
366267pub struct ZoomLevelState {
367268 pub current: ZoomVariant,
···369270370271impl Default for ZoomLevelState {
371272 fn default() -> Self {
372372- ZoomLevelState {
373373- current: ZoomVariant::Outer,
374374- }
273273+ ZoomLevelState { current: ZoomVariant::Outer }
375274 }
376275}
377276···379278 let current = ZoomVariant::from_level(state.active_level);
380279 if zoom_state.current != current {
381280 zoom_state.current = current;
382382- info!(
383383- "zoom variant → {:?} (helix level {})",
384384- current, state.active_level
385385- );
281281+ info!("zoom variant → {:?} (helix level {})", current, state.active_level);
386282 }
387283}
388284389389-/// Frame counter for throttling `cleanup_expired_events`.
390285#[derive(Resource, Default)]
391286pub struct CleanupTimer {
392287 frame_count: u32,
393288}
394289395395-/// Marker attached to event entities once their particle child has been spawned.
396290#[derive(Component)]
397291pub struct SpawnedAt(pub f32);
398292399399-/// How long (seconds) to keep an event entity alive after its particle burst
400400-/// has completed, to allow GPU particles to finish fading.
401401-///
402402-/// This is set to 2 s — comfortably above the longest particle lifetime in our
403403-/// effects (shiny inner = 1.5 s) to ensure all emitted particles have expired.
404293const PARTICLE_GRACE_SECS: f32 = 2.0;
405405-406406-/// Maximum particle lifetime across all effect variants (shiny inner = 1.5 s).
407407-/// Used as the guaranteed minimum age before we consider a spawner "done emitting
408408-/// and all particles expired."
409294const MAX_PARTICLE_LIFETIME_SECS: f32 = 1.5;
410295411296pub fn cleanup_expired_events(
···416301 window: Res<TimeWindow>,
417302 mut timer: ResMut<CleanupTimer>,
418303) {
419419- // In unbounded t-space, 1 unit = 1 minute. Keep 2 minutes of data.
420304 const CLEANUP_MARGIN: f32 = 2.0;
421305422422- // Throttle: only run every 30 frames. At 60 fps this is ~0.5 s, which is
423423- // well within the 3.5 s minimum age before any entity becomes eligible.
424306 timer.frame_count += 1;
425307 if !timer.frame_count.is_multiple_of(30) {
426308 return;
···435317 for (entity, event_marker, spawned_at) in event_query.iter() {
436318 let t = window.time_to_t(event_marker.time_us());
437319 let distance_from_focal = (t - state.focal_time).abs();
438438- let is_off_screen = distance_from_focal > CLEANUP_MARGIN;
439320440440- if !is_off_screen {
321321+ if distance_from_focal <= CLEANUP_MARGIN {
441322 continue;
442323 }
443324444444- // If it has particles, wait for them to expire before despawning.
445445- // If no particles (historical events), despawn immediately when off-screen.
446325 let should_despawn = match spawned_at {
447447- Some(sa) => {
448448- let age = now - sa.0;
449449- age >= MAX_PARTICLE_LIFETIME_SECS + PARTICLE_GRACE_SECS
450450- }
326326+ Some(sa) => (now - sa.0) >= MAX_PARTICLE_LIFETIME_SECS + PARTICLE_GRACE_SECS,
451327 None => true,
452328 };
453329···462338 use super::*;
463339 use bevy::asset::Handle;
464340465465- // -----------------------------------------------------------------------
466466- // Helper: build a minimal ParticleEffects for testing the lookup logic.
467467- //
468468- // Uses Handle::default() for all asset handles — valid for testing the pure
469469- // NSID matching logic without requiring a Bevy World or asset server.
470470- // -----------------------------------------------------------------------
471471-472341 fn dummy_zoom_effects() -> ZoomEffects {
473342 ZoomEffects {
474343 live: Handle::default(),
···483352 }
484353 }
485354486486- /// Build a ParticleEffects with post/like/follow registered, using the same
487487- /// NSID constants the production code uses.
488355 fn test_particle_effects() -> ParticleEffects {
489356 use jacquard::api::app_bsky::feed::like::LikeRecord;
490357 use jacquard::api::app_bsky::feed::post::PostRecord;
···493360 let mut keyed: Vec<(&'static str, ZoomEffects, ZoomEffects)> = vec![
494361 (PostRecord::NSID, dummy_zoom_effects(), dummy_zoom_effects()),
495362 (LikeRecord::NSID, dummy_zoom_effects(), dummy_zoom_effects()),
496496- (
497497- FollowRecord::NSID,
498498- dummy_zoom_effects(),
499499- dummy_zoom_effects(),
500500- ),
363363+ (FollowRecord::NSID, dummy_zoom_effects(), dummy_zoom_effects()),
501364 ];
502365 keyed.sort_by_key(|item| std::cmp::Reverse(item.0.len()));
503366···508371 }
509372 }
510373511511- // -----------------------------------------------------------------------
512512- // I2: NSID prefix matching tests
513513- // -----------------------------------------------------------------------
514514-515374 #[test]
516375 fn lookup_post_nsid_matches_post_entry() {
517376 use jacquard::api::app_bsky::feed::post::PostRecord;
518377 let effects = test_particle_effects();
519519- // The lookup should return the post-specific ZoomEffects (outer variant).
520520- // We can't compare Handle<EffectAsset> by value, so we verify the call
521521- // doesn't fall through to bsky_default or shiny by checking it returns
522522- // a different pointer than the dummy_zoom_pair singletons.
523523- //
524524- // Since all handles are Handle::default(), we use a pointer identity
525525- // check on the returned ZoomEffects via their address.
526378 let result = effects.lookup(Some(PostRecord::NSID), ZoomVariant::Outer);
527527- // The result must be one of the registered per-NSID entries, not bsky_default or shiny.
528379 let result_ptr = result as *const ZoomEffects;
529380 let bsky_ptr = effects.bsky_default.select(ZoomVariant::Outer) as *const ZoomEffects;
530381 let shiny_ptr = effects.shiny.select(ZoomVariant::Outer) as *const ZoomEffects;
531531- assert_ne!(
532532- result_ptr, bsky_ptr,
533533- "post NSID matched bsky_default instead of post entry"
534534- );
535535- assert_ne!(
536536- result_ptr, shiny_ptr,
537537- "post NSID matched shiny instead of post entry"
538538- );
382382+ assert_ne!(result_ptr, bsky_ptr, "post NSID matched bsky_default instead of post entry");
383383+ assert_ne!(result_ptr, shiny_ptr, "post NSID matched shiny instead of post entry");
539384 }
540385541386 #[test]
542387 fn lookup_unknown_bsky_collection_falls_to_bsky_default() {
543388 let effects = test_particle_effects();
544544- // An unregistered app.bsky.* NSID should fall through to bsky_default.
545389 let result = effects.lookup(Some("app.bsky.actor.something"), ZoomVariant::Outer);
546390 let result_ptr = result as *const ZoomEffects;
547391 let bsky_ptr = effects.bsky_default.select(ZoomVariant::Outer) as *const ZoomEffects;
548548- assert_eq!(
549549- result_ptr, bsky_ptr,
550550- "unknown app.bsky.* NSID should fall to bsky_default"
551551- );
392392+ assert_eq!(result_ptr, bsky_ptr, "unknown app.bsky.* should fall to bsky_default");
552393 }
553394554395 #[test]
555396 fn lookup_non_bsky_collection_falls_to_shiny() {
556397 let effects = test_particle_effects();
557557- // A non-bsky NSID (e.g. a third-party collection) should fall to shiny.
558398 let result = effects.lookup(Some("com.example.some.collection"), ZoomVariant::Outer);
559399 let result_ptr = result as *const ZoomEffects;
560400 let shiny_ptr = effects.shiny.select(ZoomVariant::Outer) as *const ZoomEffects;
···564404 #[test]
565405 fn lookup_none_collection_falls_to_shiny() {
566406 let effects = test_particle_effects();
567567- // None (Identity/Account events — no collection NSID) should fall to shiny.
568407 let result = effects.lookup(None, ZoomVariant::Outer);
569408 let result_ptr = result as *const ZoomEffects;
570409 let shiny_ptr = effects.shiny.select(ZoomVariant::Outer) as *const ZoomEffects;
571571- assert_eq!(
572572- result_ptr, shiny_ptr,
573573- "None collection (Identity/Account) should fall to shiny"
574574- );
410410+ assert_eq!(result_ptr, shiny_ptr, "None collection should fall to shiny");
575411 }
576412577413 #[test]
578414 fn lookup_inner_zoom_variant_selects_inner_effects() {
579415 let effects = test_particle_effects();
580580- // Verify ZoomVariant::Inner returns the inner slot, not the outer.
581416 let result_outer = effects.lookup(None, ZoomVariant::Outer) as *const ZoomEffects;
582417 let result_inner = effects.lookup(None, ZoomVariant::Inner) as *const ZoomEffects;
583583- assert_ne!(
584584- result_outer, result_inner,
585585- "outer and inner zoom variants should return different ZoomEffects"
586586- );
418418+ assert_ne!(result_outer, result_inner, "outer and inner should return different ZoomEffects");
587419 }
588420}
+74-10
src/slides.rs
···11+use bevy::asset::io::Reader;
22+use bevy::asset::{AssetLoader, LoadContext};
13use bevy::prelude::*;
24use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd, TextMergeStream};
55+3647#[derive(Debug, Clone)]
58pub struct StyledSpan {
···2326 pub fragments: Vec<SlideFragment>,
2427}
25282929+/// Bevy asset: a parsed slide deck loaded from a markdown file.
3030+#[derive(Asset, TypePath, Debug, Clone)]
3131+pub struct SlideDeckAsset {
3232+ pub slides: Vec<Slide>,
3333+}
3434+3535+/// Resource mirror of the loaded slide deck, updated on asset changes.
2636#[derive(Resource, Default)]
2737pub struct SlideDeck {
2838 pub slides: Vec<Slide>,
2939}
30404141+/// Handle to the loaded slide deck asset.
4242+#[derive(Resource)]
4343+pub struct SlideDeckHandle(pub Handle<SlideDeckAsset>);
4444+3145/// `None` = normal event card, `Some(index)` = showing slide.
3246#[derive(Resource, Default)]
3347pub struct SlideMode(pub Option<usize>);
4848+4949+5050+#[derive(Default, TypePath)]
5151+pub struct SlideDeckLoader;
5252+5353+impl AssetLoader for SlideDeckLoader {
5454+ type Asset = SlideDeckAsset;
5555+ type Settings = ();
5656+ type Error = std::io::Error;
5757+5858+ async fn load(
5959+ &self,
6060+ reader: &mut dyn Reader,
6161+ _settings: &(),
6262+ _load_context: &mut LoadContext<'_>,
6363+ ) -> Result<Self::Asset, Self::Error> {
6464+ let mut bytes = Vec::new();
6565+ reader.read_to_end(&mut bytes).await?;
6666+ let markdown =
6767+ String::from_utf8(bytes).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
6868+ let slides = parse_slide_deck(&markdown);
6969+ Ok(SlideDeckAsset { slides })
7070+ }
7171+7272+ fn extensions(&self) -> &[&str] {
7373+ &["md"]
7474+ }
7575+}
7676+34773578fn parse_slide(markdown: &str) -> Slide {
3679 let options = Options::empty();
···176219 slides
177220}
178221179179-pub fn load_slide_deck(mut commands: Commands) {
180180- let path = std::path::Path::new("assets/slides.md");
181181- match std::fs::read_to_string(path) {
182182- Ok(markdown) => {
183183- let slides = parse_slide_deck(&markdown);
184184- info!("Loaded slide deck: {} slides", slides.len());
185185- commands.insert_resource(SlideDeck { slides });
222222+223223+/// Startup: kick off the asset load.
224224+pub fn load_slide_deck(mut commands: Commands, asset_server: Res<AssetServer>) {
225225+ let handle = asset_server.load::<SlideDeckAsset>("slides.md");
226226+ commands.insert_resource(SlideDeckHandle(handle));
227227+}
228228+229229+/// Sync the `SlideDeck` resource when the asset is loaded or hot-reloaded.
230230+pub fn sync_slide_deck(
231231+ mut events: MessageReader<AssetEvent<SlideDeckAsset>>,
232232+ assets: Res<Assets<SlideDeckAsset>>,
233233+ handle: Option<Res<SlideDeckHandle>>,
234234+ mut deck: ResMut<SlideDeck>,
235235+ mut render_state: ResMut<crate::event_card::CardRenderState>,
236236+) {
237237+ let Some(handle) = handle else { return };
238238+239239+ for event in events.read() {
240240+ let id = match event {
241241+ AssetEvent::Added { id } | AssetEvent::Modified { id } => id,
242242+ _ => continue,
243243+ };
244244+245245+ if *id != handle.0.id() {
246246+ continue;
186247 }
187187- Err(e) => {
188188- warn!("Could not load slides.md: {e}");
189189- commands.insert_resource(SlideDeck::default());
248248+249249+ if let Some(asset) = assets.get(*id) {
250250+ let count = asset.slides.len();
251251+ deck.slides = asset.slides.clone();
252252+ render_state.slide_generation += 1;
253253+ info!("Slide deck reloaded: {count} slides");
190254 }
191255 }
192256}
-197
src/widgets.rs
···11-//! Simple widgets for example UI.
22-//!
33-//! Unlike other examples, which demonstrate an application, this demonstrates a plugin library.
44-55-use bevy::prelude::*;
66-77-/// An event that's sent whenever the user changes one of the settings by
88-/// clicking a radio button.
99-#[derive(Clone, Message, Deref, DerefMut)]
1010-pub struct WidgetClickEvent<T>(T);
1111-1212-/// A marker component that we place on all widgets that send
1313-/// [`WidgetClickEvent`]s of the given type.
1414-#[derive(Clone, Component, Deref, DerefMut)]
1515-pub struct WidgetClickSender<T>(T)
1616-where
1717- T: Clone + Send + Sync + 'static;
1818-1919-/// A marker component that we place on all radio `Button`s.
2020-#[derive(Clone, Copy, Component)]
2121-pub struct RadioButton;
2222-2323-/// A marker component that we place on all `Text` inside radio buttons.
2424-#[derive(Clone, Copy, Component)]
2525-pub struct RadioButtonText;
2626-2727-/// The size of the border that surrounds buttons.
2828-pub const BUTTON_BORDER: UiRect = UiRect::all(Val::Px(1.0));
2929-3030-/// The color of the border that surrounds buttons.
3131-pub const BUTTON_BORDER_COLOR: BorderColor = BorderColor {
3232- left: Color::WHITE,
3333- right: Color::WHITE,
3434- top: Color::WHITE,
3535- bottom: Color::WHITE,
3636-};
3737-3838-/// The amount of rounding to apply to button corners.
3939-pub const BUTTON_BORDER_RADIUS_SIZE: Val = Val::Px(6.0);
4040-4141-/// The amount of space between the edge of the button and its label.
4242-pub const BUTTON_PADDING: UiRect = UiRect::axes(Val::Px(12.0), Val::Px(6.0));
4343-4444-/// Returns a [`Node`] appropriate for the outer main UI node.
4545-///
4646-/// This UI is in the bottom left corner and has flex column support
4747-pub fn main_ui_node() -> Node {
4848- Node {
4949- flex_direction: FlexDirection::Column,
5050- position_type: PositionType::Absolute,
5151- row_gap: px(6),
5252- left: px(10),
5353- bottom: px(10),
5454- ..default()
5555- }
5656-}
5757-5858-/// Spawns a single radio button that allows configuration of a setting.
5959-///
6060-/// The type parameter specifies the value that will be packaged up and sent in
6161-/// a [`WidgetClickEvent`] when the radio button is clicked.
6262-pub fn option_button<T>(
6363- option_value: T,
6464- option_name: &str,
6565- is_selected: bool,
6666- is_first: bool,
6767- is_last: bool,
6868-) -> impl Bundle
6969-where
7070- T: Clone + Send + Sync + 'static,
7171-{
7272- let (bg_color, fg_color) = if is_selected {
7373- (Color::WHITE, Color::BLACK)
7474- } else {
7575- (Color::BLACK, Color::WHITE)
7676- };
7777-7878- // Add the button node.
7979- (
8080- Button,
8181- Node {
8282- border: BUTTON_BORDER.with_left(if is_first { px(1) } else { px(0) }),
8383- justify_content: JustifyContent::Center,
8484- align_items: AlignItems::Center,
8585- padding: BUTTON_PADDING,
8686- border_radius: BorderRadius::ZERO
8787- .with_left(if is_first {
8888- BUTTON_BORDER_RADIUS_SIZE
8989- } else {
9090- px(0)
9191- })
9292- .with_right(if is_last {
9393- BUTTON_BORDER_RADIUS_SIZE
9494- } else {
9595- px(0)
9696- }),
9797- ..default()
9898- },
9999- BUTTON_BORDER_COLOR,
100100- BackgroundColor(bg_color),
101101- RadioButton,
102102- WidgetClickSender(option_value.clone()),
103103- children![(
104104- ui_text(option_name, fg_color),
105105- RadioButtonText,
106106- WidgetClickSender(option_value),
107107- )],
108108- )
109109-}
110110-111111-/// Spawns the buttons that allow configuration of a setting.
112112-///
113113-/// The user may change the setting to any one of the labeled `options`. The
114114-/// value of the given type parameter will be packaged up and sent as a
115115-/// [`WidgetClickEvent`] when one of the radio buttons is clicked.
116116-pub fn option_buttons<T>(title: &str, options: &[(T, &str)]) -> impl Bundle
117117-where
118118- T: Clone + Send + Sync + 'static,
119119-{
120120- let buttons = options
121121- .iter()
122122- .cloned()
123123- .enumerate()
124124- .map(|(option_index, (option_value, option_name))| {
125125- option_button(
126126- option_value,
127127- option_name,
128128- option_index == 0,
129129- option_index == 0,
130130- option_index == options.len() - 1,
131131- )
132132- })
133133- .collect::<Vec<_>>();
134134- // Add the parent node for the row.
135135- (
136136- Node {
137137- align_items: AlignItems::Center,
138138- ..default()
139139- },
140140- Children::spawn((
141141- Spawn((
142142- ui_text(title, Color::BLACK),
143143- Node {
144144- width: px(125),
145145- ..default()
146146- },
147147- )),
148148- SpawnIter(buttons.into_iter()),
149149- )),
150150- )
151151-}
152152-153153-/// Creates a text bundle for the UI.
154154-pub fn ui_text(label: &str, color: Color) -> impl Bundle + use<> {
155155- (
156156- Text::new(label),
157157- TextFont {
158158- font_size: 18.0,
159159- ..default()
160160- },
161161- TextColor(color),
162162- )
163163-}
164164-165165-/// Checks for clicks on the radio buttons and sends `RadioButtonChangeEvent`s
166166-/// as necessary.
167167-pub fn handle_ui_interactions<T>(
168168- mut interactions: Query<
169169- (&Interaction, &WidgetClickSender<T>),
170170- (With<Button>, With<RadioButton>),
171171- >,
172172- mut widget_click_events: MessageWriter<WidgetClickEvent<T>>,
173173-) where
174174- T: Clone + Send + Sync + 'static,
175175-{
176176- for (interaction, click_event) in interactions.iter_mut() {
177177- if *interaction == Interaction::Pressed {
178178- widget_click_events.write(WidgetClickEvent((**click_event).clone()));
179179- }
180180- }
181181-}
182182-183183-/// Updates the style of the button part of a radio button to reflect its
184184-/// selected status.
185185-pub fn update_ui_radio_button(background_color: &mut BackgroundColor, selected: bool) {
186186- background_color.0 = if selected { Color::WHITE } else { Color::BLACK };
187187-}
188188-189189-/// Updates the color of the label of a radio button to reflect its selected
190190-/// status.
191191-pub fn update_ui_radio_button_text(entity: Entity, writer: &mut TextUiWriter, selected: bool) {
192192- let text_color = if selected { Color::BLACK } else { Color::WHITE };
193193-194194- writer.for_each_color(entity, |mut color| {
195195- color.0 = text_color;
196196- });
197197-}