this repo has no description

polish on slides

Orual 89973a3e 57be2114

+334 -68
+1
.gitignore
··· 22 22 *.so 23 23 *.db-shm 24 24 *.ttf 25 + *.jpg 25 26 *.db-wal 26 27 package-lock.json 27 28 *.sqlite
+69 -3
assets/slides.md
··· 57 57 58 58 --- 59 59 60 + # "Parse, don't validate" considered harmful 61 + 62 + If serde_json can't parse something as the type you're asking of it, 63 + 64 + it will give you nothing except an error. 65 + 66 + --- 67 + 68 + # "Parse, don't validate" considered harmful 69 + 70 + Jacquard has its `Data<S>` type to handle some classes of this, 71 + 72 + to work with freeform data in useful ways, 73 + 74 + but it doesn't handle the scenario where you have something that almost 75 + 76 + but not quite fits a lexicon. 77 + 78 + --- 79 + 80 + # Smart constraints 81 + 82 + ## The wonderful 'handle.invalid' 83 + 84 + - Handles are common! 85 + - You need to process lots of them 86 + - 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. 87 + 88 + --- 89 + 90 + # Smart constraints 91 + 92 + - Also a bad developer experience. 93 + - All of a sudden you can't take advantage of all the nice types! 94 + - `serde_json::Value` or `ipld_core::Ipld` offers literally nothing useful beyond "this thing is valid JSON/CBOR". 95 + 96 + --- 97 + 98 + # Smart constraints 99 + 100 + `Data<S>` is **less useless** but still sucks donkey balls when you wanted your type. 101 + 102 + ![](avasarala.jpg) 103 + 104 + --- 105 + 106 + # Smart constraints 107 + 108 + And that is why jacquard's handle constructor has a specific carveout for "handle.invalid" 109 + 110 + Because there's a time to follow the spec 111 + 112 + And there's a time to refrain 113 + 114 + --- 115 + 116 + ## Jacquard 117 + 118 + - Typestates that work for you 119 + - As pluggable as possible 120 + - Good defaults 121 + - Generated code you can just use, that's readable, easy to work with 122 + 123 + --- 124 + 60 125 ## What you're seeing 61 126 62 127 - Each dot is a Jetstream event ··· 69 134 70 135 # But... 71 136 137 + - We can change that! 72 138 73 139 --- 74 140 75 141 ## HOW? 76 142 77 143 - Standing upon the shoulders of giants here 78 - - Big party trick is the **subsecond** library 79 - - Bevy engine with hot patch support 80 - 144 + - **subsecond** library 145 + - Bevy engine with hot-patching 146 + - Jacquard beta that makes lifetimes optional 81 147 82 148 --- 83 149
+232 -55
src/event_card.rs
··· 298 298 /// ``` 299 299 pub fn setup_detail_card(mut commands: Commands, font: Res<crate::UiFont>) { 300 300 // Blueprint palette — cyan glow 301 - let glow = Color::srgba(0.3, 0.8, 1.0, 0.5); 301 + let glow = Color::srgba(0.6, 0.8, 1.0, 0.5); 302 302 let border_color = glow; 303 - let dim = TextColor(Color::srgba(0.4, 0.7, 0.9, 0.7)); 304 - let bright = TextColor(Color::srgb(0.6, 0.9, 1.0)); 303 + let dim = TextColor(Color::srgba(0.6, 0.7, 0.9, 0.7)); 304 + let bright = TextColor(Color::srgb(0.8, 0.9, 1.0)); 305 305 306 306 let font_handle = font.primary.clone(); 307 307 let make_font = |size: f32| TextFont { ··· 320 320 position_type: PositionType::Absolute, 321 321 right: Val::Px(16.0), 322 322 top: Val::Px(16.0), 323 - width: Val::Px(520.0), 324 - max_height: Val::Percent(85.0), 323 + width: Val::Px(720.0), 324 + max_height: Val::Percent(90.0), 325 325 overflow: Overflow::clip_y(), 326 326 flex_direction: FlexDirection::Column, 327 - padding: UiRect::all(Val::Px(12.0)), 328 - row_gap: Val::Px(6.0), 327 + padding: UiRect::all(Val::Px(16.0)), 328 + row_gap: Val::Px(10.0), 329 329 border: thin_border, 330 330 ..default() 331 331 }, 332 - BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.6)), 332 + BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.7)), 333 333 BorderColor::all(border_color), 334 334 )) 335 335 .with_children(|card| { ··· 345 345 row.spawn(( 346 346 AvatarImage, 347 347 Node { 348 - width: Val::Px(36.0), 349 - height: Val::Px(36.0), 348 + width: Val::Px(72.0), 349 + height: Val::Px(72.0), 350 350 border: thin_border, 351 351 ..default() 352 352 }, ··· 361 361 ..default() 362 362 }) 363 363 .with_children(|col| { 364 - col.spawn((DisplayNameText, Text::new("—"), make_font(13.0), bright)); 365 - col.spawn((HandleText, Text::new("—"), make_font(11.0), dim)); 364 + col.spawn((DisplayNameText, Text::new("—"), make_font(26.0), bright)); 365 + col.spawn((HandleText, Text::new("—"), make_font(22.0), dim)); 366 366 }); 367 367 }); 368 368 ··· 386 386 ContentText, 387 387 Text::new(""), 388 388 TextLayout::new_with_linebreak(LineBreak::WordBoundary), 389 - make_font(12.0), 389 + make_font(24.0), 390 390 bright, 391 391 )); 392 392 }); ··· 430 430 BorderColor::all(border_color), 431 431 )) 432 432 .with_children(|qp| { 433 - qp.spawn((QuotedPostAuthor, Text::new(""), make_font(10.0), dim)); 433 + qp.spawn((QuotedPostAuthor, Text::new(""), make_font(20.0), dim)); 434 434 qp.spawn(( 435 435 QuotedPostText, 436 436 Text::new(""), 437 437 TextLayout::new_with_linebreak(LineBreak::WordBoundary), 438 - make_font(11.0), 438 + make_font(22.0), 439 439 bright, 440 440 )); 441 441 }); ··· 465 465 }, 466 466 ImageNode::default(), 467 467 )); 468 - ext.spawn((ExternalLinkTitle, Text::new(""), make_font(11.0), bright)); 468 + ext.spawn((ExternalLinkTitle, Text::new(""), make_font(22.0), bright)); 469 469 ext.spawn(( 470 470 ExternalLinkDescription, 471 471 Text::new(""), 472 472 TextLayout::new_with_linebreak(LineBreak::WordBoundary), 473 - make_font(10.0), 473 + make_font(20.0), 474 474 dim, 475 475 )); 476 476 }); ··· 490 490 BorderColor::all(border_color), 491 491 )) 492 492 .with_children(|subj| { 493 - subj.spawn((SubjectLabel, Text::new(""), make_font(10.0), dim)); 494 - subj.spawn((SubjectAuthor, Text::new(""), make_font(10.0), dim)); 493 + subj.spawn((SubjectLabel, Text::new(""), make_font(20.0), dim)); 494 + subj.spawn((SubjectAuthor, Text::new(""), make_font(20.0), dim)); 495 495 subj.spawn(( 496 496 SubjectContent, 497 497 Text::new(""), 498 498 TextLayout::new_with_linebreak(LineBreak::WordBoundary), 499 - make_font(11.0), 499 + make_font(22.0), 500 500 bright, 501 501 )); 502 502 }); ··· 510 510 }, 511 511 )) 512 512 .with_children(|stats| { 513 - stats.spawn((StatsText, Text::new(""), make_font(10.0), dim)); 513 + stats.spawn((StatsText, Text::new(""), make_font(20.0), dim)); 514 514 }); 515 515 516 516 // --- Separator --- ··· 530 530 ..default() 531 531 }) 532 532 .with_children(|footer| { 533 - footer.spawn((CollectionLabel, Text::new(""), make_font(10.0), dim)); 534 - footer.spawn((TimestampLabel, Text::new(""), make_font(10.0), dim)); 533 + footer.spawn((CollectionLabel, Text::new(""), make_font(20.0), dim)); 534 + footer.spawn((TimestampLabel, Text::new(""), make_font(20.0), dim)); 535 535 }); 536 536 537 537 // --- Slide content container (hidden by default) --- ··· 552 552 pub fn update_detail_card(world: &mut World) { 553 553 use crate::slides::{SlideDeck, SlideMode}; 554 554 555 - let slide_index = world.resource::<SlideMode>().0; 555 + let slide_index = world.resource::<SlideMode>().active; 556 556 let last_slide = world.resource::<CardRenderState>().last_slide_index; 557 557 558 558 if let Some(index) = slide_index { ··· 584 584 world.resource_mut::<CardRenderState>().last_slide_index = None; 585 585 set_card_presentation_mode(world, false); 586 586 set_display::<SlideContent>(world, false); 587 + // Restore avatar visibility after slides hid it 588 + let mut q = world.query_filtered::<&mut Node, With<AvatarImage>>(); 589 + for mut node in q.iter_mut(world) { 590 + node.display = Display::Flex; 591 + } 587 592 let mut q = world.query_filtered::<&mut Node, With<DetailCard>>(); 588 593 for mut node in q.iter_mut(world) { 589 594 node.display = Display::None; ··· 815 820 node.right = Val::Px(16.0); 816 821 node.top = Val::Px(16.0); 817 822 node.bottom = Val::Auto; 818 - node.width = Val::Px(520.0); 819 - node.max_height = Val::Percent(85.0); 820 - node.padding = UiRect::all(Val::Px(12.0)); 821 - node.row_gap = Val::Px(6.0); 823 + node.width = Val::Px(720.0); 824 + node.max_height = Val::Percent(90.0); 825 + node.padding = UiRect::all(Val::Px(16.0)); 826 + node.row_gap = Val::Px(10.0); 822 827 node.justify_content = JustifyContent::default(); 823 828 } 824 829 } ··· 864 869 match fragment { 865 870 SlideFragment::Heading(level, text) => { 866 871 let font_size = match level { 867 - 1 => 52.0, 868 - 2 => 40.0, 869 - 3 => 32.0, 870 - _ => 28.0, 872 + 1 => 72.0, 873 + 2 => 56.0, 874 + 3 => 44.0, 875 + _ => 36.0, 871 876 }; 872 877 let child = world 873 878 .spawn(( ··· 888 893 world.entity_mut(content_entity).add_child(child); 889 894 } 890 895 SlideFragment::Body(spans) => { 891 - let child = spawn_styled_spans(world, &font_handle, spans, 28.0, bright, dim); 896 + let child = spawn_styled_spans(world, &font_handle, spans, 36.0, bright, dim); 892 897 world.entity_mut(content_entity).add_child(child); 893 898 } 894 899 SlideFragment::ListItem(spans) => { ··· 906 911 Text::new("•"), 907 912 TextFont { 908 913 font: font_handle.clone(), 909 - font_size: 28.0, 914 + font_size: 36.0, 910 915 ..default() 911 916 }, 912 917 TextColor(glow), ··· 914 919 .id(); 915 920 world.entity_mut(row).add_child(bullet); 916 921 917 - let text_node = spawn_styled_spans(world, &font_handle, spans, 28.0, bright, dim); 922 + let text_node = spawn_styled_spans(world, &font_handle, spans, 36.0, bright, dim); 918 923 world.entity_mut(row).add_child(text_node); 919 924 920 925 world.entity_mut(content_entity).add_child(row); ··· 932 937 Text::new(code.clone()), 933 938 TextFont { 934 939 font: font_handle.clone(), 935 - font_size: 22.0, 940 + font_size: 28.0, 936 941 ..default() 937 942 }, 938 943 TextColor(dim), ··· 955 960 .id(); 956 961 world.entity_mut(content_entity).add_child(child); 957 962 } 963 + SlideFragment::Image { path, .. } => { 964 + let server = world.resource::<AssetServer>(); 965 + let handle: Handle<Image> = server.load(path.clone()); 966 + 967 + // Wrapper prevents flex column stretch from forcing width on the image. 968 + // The image node inside uses Auto mode — inherent aspect ratio sets height. 969 + let wrapper = world 970 + .spawn(Node { 971 + align_self: AlignSelf::Center, 972 + margin: UiRect::axes(Val::Px(0.0), Val::Px(8.0)), 973 + ..default() 974 + }) 975 + .id(); 976 + 977 + let img = world 978 + .spawn(ImageNode::new(handle)) 979 + .id(); 980 + 981 + world.entity_mut(wrapper).add_child(img); 982 + world.entity_mut(content_entity).add_child(wrapper); 983 + } 984 + SlideFragment::AtEmbed(uri) => { 985 + render_slide_at_embed(world, content_entity, uri, &font_handle, bright, dim); 986 + } 987 + } 988 + } 989 + } 990 + 991 + /// Render an `at://` URI embed inside a slide, using the record cache. 992 + fn render_slide_at_embed( 993 + world: &mut World, 994 + parent: Entity, 995 + uri: &str, 996 + font: &Handle<bevy::text::Font>, 997 + bright: Color, 998 + dim: Color, 999 + ) { 1000 + let glow = Color::srgba(0.3, 0.8, 1.0, 0.5); 1001 + 1002 + // Check if the record is already cached 1003 + let cached_text = { 1004 + let cache = world.resource::<RecordCache>(); 1005 + cache.records.get(uri).map(|cached| { 1006 + let text = cached.data.get_at_path("text") 1007 + .and_then(|d| d.as_str()) 1008 + .unwrap_or("[no text]") 1009 + .to_string(); 1010 + let author = cached.author_did.as_str().to_string(); 1011 + (text, author) 1012 + }) 1013 + }; 1014 + 1015 + let border = UiRect::all(Val::Px(1.0)); 1016 + let (display_text, author_label) = cached_text 1017 + .unwrap_or_else(|| (format!("loading {uri}…"), String::new())); 1018 + 1019 + let container = world 1020 + .spawn(( 1021 + Node { 1022 + flex_direction: FlexDirection::Column, 1023 + padding: UiRect::all(Val::Px(12.0)), 1024 + row_gap: Val::Px(4.0), 1025 + border, 1026 + ..default() 1027 + }, 1028 + BorderColor::all(glow), 1029 + )) 1030 + .id(); 1031 + 1032 + if !author_label.is_empty() { 1033 + // Try to resolve author display name from profile cache 1034 + let author_display = { 1035 + use smol_str::ToSmolStr; 1036 + let profile_cache = world.resource::<ProfileCache>(); 1037 + jacquard::types::string::Did::new(author_label.to_smolstr()) 1038 + .ok() 1039 + .and_then(|did| profile_cache.profiles.get(&did)) 1040 + .map(|p| { 1041 + let name = p.display_name.as_deref().unwrap_or(&p.handle); 1042 + format!("{name} (@{})", p.handle) 1043 + }) 1044 + .unwrap_or(author_label) 1045 + }; 1046 + 1047 + let author_node = world 1048 + .spawn(( 1049 + Text::new(author_display), 1050 + TextFont { font: font.clone(), font_size: 28.0, ..default() }, 1051 + TextColor(dim), 1052 + )) 1053 + .id(); 1054 + world.entity_mut(container).add_child(author_node); 1055 + } 1056 + 1057 + let text_node = world 1058 + .spawn(( 1059 + Text::new(display_text), 1060 + TextFont { font: font.clone(), font_size: 32.0, ..default() }, 1061 + TextColor(bright), 1062 + TextLayout::new_with_linebreak(LineBreak::WordBoundary), 1063 + )) 1064 + .id(); 1065 + world.entity_mut(container).add_child(text_node); 1066 + world.entity_mut(parent).add_child(container); 1067 + 1068 + // Trigger a fetch if not cached 1069 + let needs_fetch = { 1070 + let cache = world.resource::<RecordCache>(); 1071 + !cache.records.contains_key(uri) && !cache.records_pending.contains(uri) 1072 + }; 1073 + if needs_fetch { 1074 + let mut cache = world.resource_mut::<RecordCache>(); 1075 + cache.records_pending.insert(uri.to_string()); 1076 + // The existing request_enrichment_for_selected system won't fetch this 1077 + // since it's not a selected event. We'll trigger it via the XRPC pipe. 1078 + if let Some(atp_client) = world.get_resource::<crate::net::AtpClient>() { 1079 + let client = atp_client.0.clone(); 1080 + let uri_owned = uri.to_string(); 1081 + let runtime = world.resource::<bevy_tokio_tasks::TokioTasksRuntime>(); 1082 + runtime.spawn_background_task(move |mut ctx| async move { 1083 + let result = fetch_record_by_uri(&client, &uri_owned).await; 1084 + ctx.run_on_main_thread(move |ctx| { 1085 + let world = ctx.world; 1086 + let mut cache = world.resource_mut::<RecordCache>(); 1087 + cache.records_pending.remove(&uri_owned); 1088 + if let Some((data, author_did)) = result { 1089 + cache.records.insert(uri_owned, CachedRecord { data, author_did }); 1090 + cache.bump(); 1091 + } 1092 + }); 1093 + }); 958 1094 } 959 1095 } 960 1096 } ··· 967 1103 bright: Color, 968 1104 _dim: Color, 969 1105 ) -> Entity { 970 - let mut full_text = String::new(); 1106 + let ui_font = world.resource::<crate::UiFont>(); 1107 + let bold_font = ui_font.bold.clone(); 1108 + let italic_font = ui_font.italic.clone(); 1109 + 1110 + // If all spans share the same style, use a single Text node 1111 + let all_same = spans.iter().all(|s| !s.bold && !s.italic && !s.code); 1112 + if all_same { 1113 + let mut full_text = String::new(); 1114 + for span in spans { 1115 + full_text.push_str(&span.text); 1116 + } 1117 + return world 1118 + .spawn(( 1119 + Text::new(full_text), 1120 + TextFont { font: font.clone(), font_size, ..default() }, 1121 + TextColor(bright), 1122 + TextLayout::new_with_linebreak(LineBreak::WordBoundary), 1123 + )) 1124 + .id(); 1125 + } 1126 + 1127 + // Mixed styles: use a row of individually-styled text nodes 1128 + let row = world 1129 + .spawn(Node { 1130 + flex_direction: FlexDirection::Row, 1131 + flex_wrap: FlexWrap::Wrap, 1132 + ..default() 1133 + }) 1134 + .id(); 1135 + 971 1136 for span in spans { 972 - full_text.push_str(&span.text); 1137 + let span_font = if span.bold { 1138 + bold_font.clone() 1139 + } else if span.italic { 1140 + italic_font.clone() 1141 + } else { 1142 + font.clone() 1143 + }; 1144 + 1145 + let child = world 1146 + .spawn(( 1147 + Text::new(span.text.clone()), 1148 + TextFont { font: span_font, font_size, ..default() }, 1149 + TextColor(bright), 1150 + )) 1151 + .id(); 1152 + world.entity_mut(row).add_child(child); 973 1153 } 974 1154 975 - world 976 - .spawn(( 977 - Text::new(full_text), 978 - TextFont { 979 - font: font.clone(), 980 - font_size, 981 - ..default() 982 - }, 983 - TextColor(bright), 984 - TextLayout::new_with_linebreak(LineBreak::WordBoundary), 985 - )) 986 - .id() 1155 + row 1156 + } 1157 + 1158 + async fn fetch_record_by_uri( 1159 + client: &std::sync::Arc<jacquard::client::BasicClient>, 1160 + uri: &str, 1161 + ) -> Option<(jacquard::Data, jacquard::types::string::Did)> { 1162 + use jacquard::types::string::Did; 1163 + use smol_str::ToSmolStr; 1164 + 1165 + let parsed = AtUri::new(uri.to_smolstr()).ok()?; 1166 + let output = client.fetch_record_slingshot(&parsed).await.ok()?; 1167 + let author = Did::new(parsed.authority().as_str().to_smolstr()).ok()?; 1168 + Some((output.value, author)) 987 1169 } 988 1170 989 1171 fn load_cdn_image(world: &World, did: &str, cid: &str) -> Handle<Image> { ··· 2043 2225 }; 2044 2226 use jacquard::types::string::{Datetime, Did, Nsid, Rkey}; 2045 2227 2046 - 2047 2228 fn make_post_marker(text: &str) -> JetstreamEventMarker { 2048 2229 let did = Did::new_static("did:plc:testuser123456789").expect("valid DID"); 2049 2230 let collection = Nsid::new_static("app.bsky.feed.post").expect("valid NSID"); ··· 2176 2357 }) 2177 2358 } 2178 2359 2179 - 2180 2360 #[test] 2181 2361 fn dispatch_routes_post_to_text_content() { 2182 2362 let marker = make_post_marker("Hello, ATmosphere!"); ··· 2248 2428 _ => panic!("expected Account"), 2249 2429 } 2250 2430 } 2251 - 2252 2431 2253 2432 #[test] 2254 2433 fn post_extracts_text_field() { ··· 2283 2462 } 2284 2463 } 2285 2464 2286 - 2287 2465 #[test] 2288 2466 fn unknown_shows_collection_nsid() { 2289 2467 let nsid = Nsid::new_static("app.bsky.graph.list").expect("valid NSID"); ··· 2343 2521 _ => panic!("expected Unknown"), 2344 2522 } 2345 2523 } 2346 - 2347 2524 2348 2525 #[test] 2349 2526 fn parse_image_embed() {
+5 -2
src/main.rs
··· 109 109 .run(); 110 110 } 111 111 112 - /// Primary + emoji font handles. 113 112 #[derive(Resource)] 114 113 pub struct UiFont { 115 114 pub primary: bevy::asset::Handle<bevy::text::Font>, 115 + pub bold: bevy::asset::Handle<bevy::text::Font>, 116 + pub italic: bevy::asset::Handle<bevy::text::Font>, 116 117 pub emoji: bevy::asset::Handle<bevy::text::Font>, 117 118 } 118 119 ··· 120 121 fn from_world(world: &mut World) -> Self { 121 122 let asset_server = world.resource::<AssetServer>(); 122 123 UiFont { 123 - primary: asset_server.load("fonts/IBMPlexMono-Regular.ttf"), 124 + primary: asset_server.load("fonts/IosevkaIoskeley-Regular.ttf"), 125 + bold: asset_server.load("fonts/IosevkaIoskeley-Bold.ttf"), 126 + italic: asset_server.load("fonts/IosevkaIoskeley-Italic.ttf"), 124 127 emoji: asset_server.load("fonts/NotoColorEmoji.ttf"), 125 128 } 126 129 }
+27 -8
src/slides.rs
··· 19 19 Code(String), 20 20 Rule, 21 21 ListItem(Vec<StyledSpan>), 22 + /// Local image from assets/ directory. 23 + Image { path: String, alt: String }, 24 + /// AT URI embed — rendered using the event card pipeline. 25 + AtEmbed(String), 22 26 } 23 27 24 28 #[derive(Debug, Clone)] ··· 44 48 45 49 /// `None` = normal event card, `Some(index)` = showing slide. 46 50 #[derive(Resource, Default)] 47 - pub struct SlideMode(pub Option<usize>); 51 + pub struct SlideMode { 52 + pub active: Option<usize>, 53 + pub last_index: usize, 54 + } 48 55 49 56 50 57 #[derive(Default, TypePath)] ··· 145 152 in_list_item = false; 146 153 } 147 154 Event::Start(Tag::List(_)) | Event::End(TagEnd::List(_)) => {} 155 + Event::Start(Tag::Image { dest_url, .. }) => { 156 + let url = dest_url.to_string(); 157 + if url.starts_with("at://") { 158 + fragments.push(SlideFragment::AtEmbed(url)); 159 + } else { 160 + // Collect alt text from subsequent Text events 161 + // For now, use empty alt — the End(Image) will fire next 162 + fragments.push(SlideFragment::Image { path: url, alt: String::new() }); 163 + } 164 + } 165 + Event::End(TagEnd::Image) => {} 148 166 Event::Text(text) => { 149 167 if in_code_block { 150 168 code_buf.push_str(&text); ··· 273 291 let dismiss = keys.just_pressed(KeyCode::Escape); 274 292 275 293 if dismiss { 276 - if mode.0.is_some() { 277 - mode.0 = None; 294 + if let Some(i) = mode.active { 295 + mode.last_index = i; 296 + mode.active = None; 278 297 helix_state.time_scale = 1.0; 279 298 info!("Slides dismissed"); 280 299 } ··· 282 301 } 283 302 284 303 if advance { 285 - let next = match mode.0 { 304 + let next = match mode.active { 286 305 None => { 287 306 helix_state.time_scale = SLIDE_TIME_SCALE; 288 - 0 307 + mode.last_index 289 308 } 290 309 Some(i) => (i + 1).min(deck.slides.len() - 1), 291 310 }; 292 - mode.0 = Some(next); 311 + mode.active = Some(next); 293 312 info!("Slide {}/{}", next + 1, deck.slides.len()); 294 313 } 295 314 296 315 if retreat { 297 - if let Some(i) = mode.0 { 316 + if let Some(i) = mode.active { 298 317 if i > 0 { 299 - mode.0 = Some(i - 1); 318 + mode.active = Some(i - 1); 300 319 info!("Slide {}/{}", i, deck.slides.len()); 301 320 } 302 321 }