diff --git a/.gitignore b/.gitignore index e4acfa6..ae97c4b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ result *.so *.db-shm *.ttf +*.jpg *.db-wal package-lock.json *.sqlite diff --git a/assets/slides.md b/assets/slides.md index 7be4876..729fc58 100644 --- a/assets/slides.md +++ b/assets/slides.md @@ -57,6 +57,71 @@ and it's not a stupid idea to. --- +# "Parse, don't validate" considered harmful + +If serde_json can't parse something as the type you're asking of it, + +it will give you nothing except an error. + +--- + +# "Parse, don't validate" considered harmful + +Jacquard has its `Data` type to handle some classes of this, + +to work with freeform data in useful ways, + +but it doesn't handle the scenario where you have something that almost + +but not quite fits a lexicon. + +--- + +# Smart constraints + +## The wonderful 'handle.invalid' + +- Handles are common! +- You need to process lots of them +- If you choke at the deserialization stage bc one post in a list has an author who's handle is invalid, that's not good as a user experience. + +--- + +# Smart constraints + +- Also a bad developer experience. +- All of a sudden you can't take advantage of all the nice types! +- `serde_json::Value` or `ipld_core::Ipld` offers literally nothing useful beyond "this thing is valid JSON/CBOR". + +--- + +# Smart constraints + +`Data` is **less useless** but still sucks donkey balls when you wanted your type. + +![](avasarala.jpg) + +--- + +# Smart constraints + +And that is why jacquard's handle constructor has a specific carveout for "handle.invalid" + +Because there's a time to follow the spec + +And there's a time to refrain + +--- + +## Jacquard + +- Typestates that work for you +- As pluggable as possible +- Good defaults +- Generated code you can just use, that's readable, easy to work with + +--- + ## What you're seeing - Each dot is a Jetstream event @@ -69,15 +134,16 @@ and it's not a stupid idea to. # But... +- We can change that! --- ## HOW? - Standing upon the shoulders of giants here -- Big party trick is the **subsecond** library -- Bevy engine with hot patch support - +- **subsecond** library +- Bevy engine with hot-patching +- Jacquard beta that makes lifetimes optional --- diff --git a/src/event_card.rs b/src/event_card.rs index c16905e..206c76a 100644 --- a/src/event_card.rs +++ b/src/event_card.rs @@ -298,10 +298,10 @@ impl DataDisplay { /// ``` pub fn setup_detail_card(mut commands: Commands, font: Res) { // Blueprint palette — cyan glow - let glow = Color::srgba(0.3, 0.8, 1.0, 0.5); + let glow = Color::srgba(0.6, 0.8, 1.0, 0.5); let border_color = glow; - let dim = TextColor(Color::srgba(0.4, 0.7, 0.9, 0.7)); - let bright = TextColor(Color::srgb(0.6, 0.9, 1.0)); + let dim = TextColor(Color::srgba(0.6, 0.7, 0.9, 0.7)); + let bright = TextColor(Color::srgb(0.8, 0.9, 1.0)); let font_handle = font.primary.clone(); let make_font = |size: f32| TextFont { @@ -320,16 +320,16 @@ pub fn setup_detail_card(mut commands: Commands, font: Res) { position_type: PositionType::Absolute, right: Val::Px(16.0), top: Val::Px(16.0), - width: Val::Px(520.0), - max_height: Val::Percent(85.0), + width: Val::Px(720.0), + max_height: Val::Percent(90.0), overflow: Overflow::clip_y(), flex_direction: FlexDirection::Column, - padding: UiRect::all(Val::Px(12.0)), - row_gap: Val::Px(6.0), + padding: UiRect::all(Val::Px(16.0)), + row_gap: Val::Px(10.0), border: thin_border, ..default() }, - BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.6)), + BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.7)), BorderColor::all(border_color), )) .with_children(|card| { @@ -345,8 +345,8 @@ pub fn setup_detail_card(mut commands: Commands, font: Res) { row.spawn(( AvatarImage, Node { - width: Val::Px(36.0), - height: Val::Px(36.0), + width: Val::Px(72.0), + height: Val::Px(72.0), border: thin_border, ..default() }, @@ -361,8 +361,8 @@ pub fn setup_detail_card(mut commands: Commands, font: Res) { ..default() }) .with_children(|col| { - col.spawn((DisplayNameText, Text::new("—"), make_font(13.0), bright)); - col.spawn((HandleText, Text::new("—"), make_font(11.0), dim)); + col.spawn((DisplayNameText, Text::new("—"), make_font(26.0), bright)); + col.spawn((HandleText, Text::new("—"), make_font(22.0), dim)); }); }); @@ -386,7 +386,7 @@ pub fn setup_detail_card(mut commands: Commands, font: Res) { ContentText, Text::new(""), TextLayout::new_with_linebreak(LineBreak::WordBoundary), - make_font(12.0), + make_font(24.0), bright, )); }); @@ -430,12 +430,12 @@ pub fn setup_detail_card(mut commands: Commands, font: Res) { BorderColor::all(border_color), )) .with_children(|qp| { - qp.spawn((QuotedPostAuthor, Text::new(""), make_font(10.0), dim)); + qp.spawn((QuotedPostAuthor, Text::new(""), make_font(20.0), dim)); qp.spawn(( QuotedPostText, Text::new(""), TextLayout::new_with_linebreak(LineBreak::WordBoundary), - make_font(11.0), + make_font(22.0), bright, )); }); @@ -465,12 +465,12 @@ pub fn setup_detail_card(mut commands: Commands, font: Res) { }, ImageNode::default(), )); - ext.spawn((ExternalLinkTitle, Text::new(""), make_font(11.0), bright)); + ext.spawn((ExternalLinkTitle, Text::new(""), make_font(22.0), bright)); ext.spawn(( ExternalLinkDescription, Text::new(""), TextLayout::new_with_linebreak(LineBreak::WordBoundary), - make_font(10.0), + make_font(20.0), dim, )); }); @@ -490,13 +490,13 @@ pub fn setup_detail_card(mut commands: Commands, font: Res) { BorderColor::all(border_color), )) .with_children(|subj| { - subj.spawn((SubjectLabel, Text::new(""), make_font(10.0), dim)); - subj.spawn((SubjectAuthor, Text::new(""), make_font(10.0), dim)); + subj.spawn((SubjectLabel, Text::new(""), make_font(20.0), dim)); + subj.spawn((SubjectAuthor, Text::new(""), make_font(20.0), dim)); subj.spawn(( SubjectContent, Text::new(""), TextLayout::new_with_linebreak(LineBreak::WordBoundary), - make_font(11.0), + make_font(22.0), bright, )); }); @@ -510,7 +510,7 @@ pub fn setup_detail_card(mut commands: Commands, font: Res) { }, )) .with_children(|stats| { - stats.spawn((StatsText, Text::new(""), make_font(10.0), dim)); + stats.spawn((StatsText, Text::new(""), make_font(20.0), dim)); }); // --- Separator --- @@ -530,8 +530,8 @@ pub fn setup_detail_card(mut commands: Commands, font: Res) { ..default() }) .with_children(|footer| { - footer.spawn((CollectionLabel, Text::new(""), make_font(10.0), dim)); - footer.spawn((TimestampLabel, Text::new(""), make_font(10.0), dim)); + footer.spawn((CollectionLabel, Text::new(""), make_font(20.0), dim)); + footer.spawn((TimestampLabel, Text::new(""), make_font(20.0), dim)); }); // --- Slide content container (hidden by default) --- @@ -552,7 +552,7 @@ pub fn setup_detail_card(mut commands: Commands, font: Res) { pub fn update_detail_card(world: &mut World) { use crate::slides::{SlideDeck, SlideMode}; - let slide_index = world.resource::().0; + let slide_index = world.resource::().active; let last_slide = world.resource::().last_slide_index; if let Some(index) = slide_index { @@ -584,6 +584,11 @@ pub fn update_detail_card(world: &mut World) { world.resource_mut::().last_slide_index = None; set_card_presentation_mode(world, false); set_display::(world, false); + // Restore avatar visibility after slides hid it + let mut q = world.query_filtered::<&mut Node, With>(); + for mut node in q.iter_mut(world) { + node.display = Display::Flex; + } let mut q = world.query_filtered::<&mut Node, With>(); for mut node in q.iter_mut(world) { node.display = Display::None; @@ -815,10 +820,10 @@ fn set_card_presentation_mode(world: &mut World, presentation: bool) { node.right = Val::Px(16.0); node.top = Val::Px(16.0); node.bottom = Val::Auto; - node.width = Val::Px(520.0); - node.max_height = Val::Percent(85.0); - node.padding = UiRect::all(Val::Px(12.0)); - node.row_gap = Val::Px(6.0); + node.width = Val::Px(720.0); + node.max_height = Val::Percent(90.0); + node.padding = UiRect::all(Val::Px(16.0)); + node.row_gap = Val::Px(10.0); node.justify_content = JustifyContent::default(); } } @@ -864,10 +869,10 @@ fn render_slide_content(world: &mut World, slide: &crate::slides::Slide) { match fragment { SlideFragment::Heading(level, text) => { let font_size = match level { - 1 => 52.0, - 2 => 40.0, - 3 => 32.0, - _ => 28.0, + 1 => 72.0, + 2 => 56.0, + 3 => 44.0, + _ => 36.0, }; let child = world .spawn(( @@ -888,7 +893,7 @@ fn render_slide_content(world: &mut World, slide: &crate::slides::Slide) { world.entity_mut(content_entity).add_child(child); } SlideFragment::Body(spans) => { - let child = spawn_styled_spans(world, &font_handle, spans, 28.0, bright, dim); + let child = spawn_styled_spans(world, &font_handle, spans, 36.0, bright, dim); world.entity_mut(content_entity).add_child(child); } SlideFragment::ListItem(spans) => { @@ -906,7 +911,7 @@ fn render_slide_content(world: &mut World, slide: &crate::slides::Slide) { Text::new("•"), TextFont { font: font_handle.clone(), - font_size: 28.0, + font_size: 36.0, ..default() }, TextColor(glow), @@ -914,7 +919,7 @@ fn render_slide_content(world: &mut World, slide: &crate::slides::Slide) { .id(); world.entity_mut(row).add_child(bullet); - let text_node = spawn_styled_spans(world, &font_handle, spans, 28.0, bright, dim); + let text_node = spawn_styled_spans(world, &font_handle, spans, 36.0, bright, dim); world.entity_mut(row).add_child(text_node); world.entity_mut(content_entity).add_child(row); @@ -932,7 +937,7 @@ fn render_slide_content(world: &mut World, slide: &crate::slides::Slide) { Text::new(code.clone()), TextFont { font: font_handle.clone(), - font_size: 22.0, + font_size: 28.0, ..default() }, TextColor(dim), @@ -955,6 +960,137 @@ fn render_slide_content(world: &mut World, slide: &crate::slides::Slide) { .id(); world.entity_mut(content_entity).add_child(child); } + SlideFragment::Image { path, .. } => { + let server = world.resource::(); + let handle: Handle = server.load(path.clone()); + + // Wrapper prevents flex column stretch from forcing width on the image. + // The image node inside uses Auto mode — inherent aspect ratio sets height. + let wrapper = world + .spawn(Node { + align_self: AlignSelf::Center, + margin: UiRect::axes(Val::Px(0.0), Val::Px(8.0)), + ..default() + }) + .id(); + + let img = world + .spawn(ImageNode::new(handle)) + .id(); + + world.entity_mut(wrapper).add_child(img); + world.entity_mut(content_entity).add_child(wrapper); + } + SlideFragment::AtEmbed(uri) => { + render_slide_at_embed(world, content_entity, uri, &font_handle, bright, dim); + } + } + } +} + +/// Render an `at://` URI embed inside a slide, using the record cache. +fn render_slide_at_embed( + world: &mut World, + parent: Entity, + uri: &str, + font: &Handle, + bright: Color, + dim: Color, +) { + let glow = Color::srgba(0.3, 0.8, 1.0, 0.5); + + // Check if the record is already cached + let cached_text = { + let cache = world.resource::(); + cache.records.get(uri).map(|cached| { + let text = cached.data.get_at_path("text") + .and_then(|d| d.as_str()) + .unwrap_or("[no text]") + .to_string(); + let author = cached.author_did.as_str().to_string(); + (text, author) + }) + }; + + let border = UiRect::all(Val::Px(1.0)); + let (display_text, author_label) = cached_text + .unwrap_or_else(|| (format!("loading {uri}…"), String::new())); + + let container = world + .spawn(( + Node { + flex_direction: FlexDirection::Column, + padding: UiRect::all(Val::Px(12.0)), + row_gap: Val::Px(4.0), + border, + ..default() + }, + BorderColor::all(glow), + )) + .id(); + + if !author_label.is_empty() { + // Try to resolve author display name from profile cache + let author_display = { + use smol_str::ToSmolStr; + let profile_cache = world.resource::(); + jacquard::types::string::Did::new(author_label.to_smolstr()) + .ok() + .and_then(|did| profile_cache.profiles.get(&did)) + .map(|p| { + let name = p.display_name.as_deref().unwrap_or(&p.handle); + format!("{name} (@{})", p.handle) + }) + .unwrap_or(author_label) + }; + + let author_node = world + .spawn(( + Text::new(author_display), + TextFont { font: font.clone(), font_size: 28.0, ..default() }, + TextColor(dim), + )) + .id(); + world.entity_mut(container).add_child(author_node); + } + + let text_node = world + .spawn(( + Text::new(display_text), + TextFont { font: font.clone(), font_size: 32.0, ..default() }, + TextColor(bright), + TextLayout::new_with_linebreak(LineBreak::WordBoundary), + )) + .id(); + world.entity_mut(container).add_child(text_node); + world.entity_mut(parent).add_child(container); + + // Trigger a fetch if not cached + let needs_fetch = { + let cache = world.resource::(); + !cache.records.contains_key(uri) && !cache.records_pending.contains(uri) + }; + if needs_fetch { + let mut cache = world.resource_mut::(); + cache.records_pending.insert(uri.to_string()); + // The existing request_enrichment_for_selected system won't fetch this + // since it's not a selected event. We'll trigger it via the XRPC pipe. + if let Some(atp_client) = world.get_resource::() { + let client = atp_client.0.clone(); + let uri_owned = uri.to_string(); + let runtime = world.resource::(); + runtime.spawn_background_task(move |mut ctx| async move { + let result = fetch_record_by_uri(&client, &uri_owned).await; + ctx.run_on_main_thread(move |ctx| { + let world = ctx.world; + let mut cache = world.resource_mut::(); + cache.records_pending.remove(&uri_owned); + if let Some((data, author_did)) = result { + cache.records.insert(uri_owned, CachedRecord { data, author_did }); + cache.bump(); + } + }); + }); } } } @@ -967,23 +1103,69 @@ fn spawn_styled_spans( bright: Color, _dim: Color, ) -> Entity { - let mut full_text = String::new(); + let ui_font = world.resource::(); + let bold_font = ui_font.bold.clone(); + let italic_font = ui_font.italic.clone(); + + // If all spans share the same style, use a single Text node + let all_same = spans.iter().all(|s| !s.bold && !s.italic && !s.code); + if all_same { + let mut full_text = String::new(); + for span in spans { + full_text.push_str(&span.text); + } + return world + .spawn(( + Text::new(full_text), + TextFont { font: font.clone(), font_size, ..default() }, + TextColor(bright), + TextLayout::new_with_linebreak(LineBreak::WordBoundary), + )) + .id(); + } + + // Mixed styles: use a row of individually-styled text nodes + let row = world + .spawn(Node { + flex_direction: FlexDirection::Row, + flex_wrap: FlexWrap::Wrap, + ..default() + }) + .id(); + for span in spans { - full_text.push_str(&span.text); + let span_font = if span.bold { + bold_font.clone() + } else if span.italic { + italic_font.clone() + } else { + font.clone() + }; + + let child = world + .spawn(( + Text::new(span.text.clone()), + TextFont { font: span_font, font_size, ..default() }, + TextColor(bright), + )) + .id(); + world.entity_mut(row).add_child(child); } - world - .spawn(( - Text::new(full_text), - TextFont { - font: font.clone(), - font_size, - ..default() - }, - TextColor(bright), - TextLayout::new_with_linebreak(LineBreak::WordBoundary), - )) - .id() + row +} + +async fn fetch_record_by_uri( + client: &std::sync::Arc, + uri: &str, +) -> Option<(jacquard::Data, jacquard::types::string::Did)> { + use jacquard::types::string::Did; + use smol_str::ToSmolStr; + + let parsed = AtUri::new(uri.to_smolstr()).ok()?; + let output = client.fetch_record_slingshot(&parsed).await.ok()?; + let author = Did::new(parsed.authority().as_str().to_smolstr()).ok()?; + Some((output.value, author)) } fn load_cdn_image(world: &World, did: &str, cid: &str) -> Handle { @@ -2043,7 +2225,6 @@ mod tests { }; use jacquard::types::string::{Datetime, Did, Nsid, Rkey}; - fn make_post_marker(text: &str) -> JetstreamEventMarker { let did = Did::new_static("did:plc:testuser123456789").expect("valid DID"); let collection = Nsid::new_static("app.bsky.feed.post").expect("valid NSID"); @@ -2176,7 +2357,6 @@ mod tests { }) } - #[test] fn dispatch_routes_post_to_text_content() { let marker = make_post_marker("Hello, ATmosphere!"); @@ -2249,7 +2429,6 @@ mod tests { } } - #[test] fn post_extracts_text_field() { let data: jacquard::Data = serde_json::from_str( @@ -2283,7 +2462,6 @@ mod tests { } } - #[test] fn unknown_shows_collection_nsid() { let nsid = Nsid::new_static("app.bsky.graph.list").expect("valid NSID"); @@ -2344,7 +2522,6 @@ mod tests { } } - #[test] fn parse_image_embed() { let data: jacquard::Data = serde_json::from_str(r#"{ diff --git a/src/main.rs b/src/main.rs index 229f8a6..4082e2f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -109,10 +109,11 @@ fn main() { .run(); } -/// Primary + emoji font handles. #[derive(Resource)] pub struct UiFont { pub primary: bevy::asset::Handle, + pub bold: bevy::asset::Handle, + pub italic: bevy::asset::Handle, pub emoji: bevy::asset::Handle, } @@ -120,7 +121,9 @@ impl FromWorld for UiFont { fn from_world(world: &mut World) -> Self { let asset_server = world.resource::(); UiFont { - primary: asset_server.load("fonts/IBMPlexMono-Regular.ttf"), + primary: asset_server.load("fonts/IosevkaIoskeley-Regular.ttf"), + bold: asset_server.load("fonts/IosevkaIoskeley-Bold.ttf"), + italic: asset_server.load("fonts/IosevkaIoskeley-Italic.ttf"), emoji: asset_server.load("fonts/NotoColorEmoji.ttf"), } } diff --git a/src/slides.rs b/src/slides.rs index 01d51e6..4a12df8 100644 --- a/src/slides.rs +++ b/src/slides.rs @@ -19,6 +19,10 @@ pub enum SlideFragment { Code(String), Rule, ListItem(Vec), + /// Local image from assets/ directory. + Image { path: String, alt: String }, + /// AT URI embed — rendered using the event card pipeline. + AtEmbed(String), } #[derive(Debug, Clone)] @@ -44,7 +48,10 @@ pub struct SlideDeckHandle(pub Handle); /// `None` = normal event card, `Some(index)` = showing slide. #[derive(Resource, Default)] -pub struct SlideMode(pub Option); +pub struct SlideMode { + pub active: Option, + pub last_index: usize, +} #[derive(Default, TypePath)] @@ -145,6 +152,17 @@ fn parse_slide(markdown: &str) -> Slide { in_list_item = false; } Event::Start(Tag::List(_)) | Event::End(TagEnd::List(_)) => {} + Event::Start(Tag::Image { dest_url, .. }) => { + let url = dest_url.to_string(); + if url.starts_with("at://") { + fragments.push(SlideFragment::AtEmbed(url)); + } else { + // Collect alt text from subsequent Text events + // For now, use empty alt — the End(Image) will fire next + fragments.push(SlideFragment::Image { path: url, alt: String::new() }); + } + } + Event::End(TagEnd::Image) => {} Event::Text(text) => { if in_code_block { code_buf.push_str(&text); @@ -273,8 +291,9 @@ pub fn slide_navigation( let dismiss = keys.just_pressed(KeyCode::Escape); if dismiss { - if mode.0.is_some() { - mode.0 = None; + if let Some(i) = mode.active { + mode.last_index = i; + mode.active = None; helix_state.time_scale = 1.0; info!("Slides dismissed"); } @@ -282,21 +301,21 @@ pub fn slide_navigation( } if advance { - let next = match mode.0 { + let next = match mode.active { None => { helix_state.time_scale = SLIDE_TIME_SCALE; - 0 + mode.last_index } Some(i) => (i + 1).min(deck.slides.len() - 1), }; - mode.0 = Some(next); + mode.active = Some(next); info!("Slide {}/{}", next + 1, deck.slides.len()); } if retreat { - if let Some(i) = mode.0 { + if let Some(i) = mode.active { if i > 0 { - mode.0 = Some(i - 1); + mode.active = Some(i - 1); info!("Slide {}/{}", i, deck.slides.len()); } }