From c238d933df560dcc06d0ef20889c372e89e3724a Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 13 Oct 2026 12:48:28 +0300 Subject: [PATCH 1/2] Mark the current transition effect option (#365) The Effect Options menu drew every direction as the same button, so the active one was invisible. Co-authored-by: Cursor --- crates/ui-egui/src/ribbon.rs | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..e5354df7 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1378,12 +1378,14 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { }); }); group(ui, |ui| { - let opts: &[&str] = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == kind).map(|x| x.4).unwrap_or(&[]); - let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !opts.is_empty()); + let selected = cur.as_ref().map(|t| t.option.as_str()); + let rows = transition_effect_rows(&kind, selected); + let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !rows.is_empty()); egui::Popup::menu(&r).show(|ui| { - for o in opts { - if ui.button(deckcraft_model::anim::transition_option_label(&kind, o)).clicked() { + for &(o, on) in &rows { + if ui.selectable_label(on, deckcraft_model::anim::transition_option_label(&kind, o)).clicked() { run(app, "transition.options", json!({"option": o})); + ui.close(); } } }); @@ -2408,6 +2410,11 @@ fn slide_master(app: &mut SlideApp, ui: &mut Ui) { }); } +/// One row of Transitions ▸ Effect Options: the stored option and whether it is the current one. +fn transition_effect_rows(kind: &str, current: Option<&str>) -> Vec<(&'static str, bool)> { + deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == kind).map(|x| x.4).unwrap_or(&[]).iter().map(|o| (*o, current == Some(*o))).collect() +} + /// Gallery glyphs for the freeform tools: a wave (curve), a polygon (freeform), a squiggle (scribble). fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { let at = |x: f32, y: f32| egui::pos2(r.min.x + x * r.width(), r.min.y + y * r.height()); @@ -2420,3 +2427,18 @@ fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { }; p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink))); } + +#[cfg(test)] +mod tests { + use super::transition_effect_rows; + + #[test] + fn effect_options_mark_the_current_transition_option() { + let on = |kind, current| transition_effect_rows(kind, current).into_iter().map(|(_, marked)| marked).collect::>(); + assert_eq!(on("push", Some("l")), vec![false, false, true, false]); + assert_eq!(on("fade", Some("throughBlack")), vec![false, true]); + assert_eq!(on("push", Some("missing")), vec![false, false, false, false]); + assert!(on("none", None).is_empty()); + assert_eq!(transition_effect_rows("cut", Some("")).first().map(|(o, marked)| (*o, *marked)), Some(("", true))); + } +} From 8b1836bc5320f6c2bebc724c1453c75912bfb253 Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 13 Oct 2026 13:10:24 +0300 Subject: [PATCH 2/2] Effect Options marks the stored choice for animations too (#365) The same rule that highlights a transition direction now highlights an animation's direction and text build. Co-authored-by: Cursor --- crates/ui-egui/src/ribbon.rs | 69 +++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e5354df7..12eab294 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1593,18 +1593,23 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { }); } }); - let opts: &[&str] = cur.as_ref().and_then(|c| deckcraft_model::anim::animation_info(&c.effect, c.class)).map(|x| x.5).unwrap_or(&[]); - let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !opts.is_empty()); + let effect_rows = cur.as_ref().map(|c| animation_effect_rows(&c.effect, c.class, Some(c.option.as_str()))).unwrap_or_default(); + let build = cur.as_ref().map(|c| text_build_id(c.text_build)); + let build_rows = marked_options(&["asOne", "byParagraph"], build); + let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !effect_rows.is_empty()); egui::Popup::menu(&r).show(|ui| { - for o in opts { - if ui.button(option_label(o)).clicked() { + for &(o, on) in &effect_rows { + if ui.selectable_label(on, option_label(o)).clicked() { run(app, "animation.options", json!({"option": o})); + ui.close(); } } ui.separator(); - for (l, b) in [("As One Object", "asOne"), ("By Paragraph", "byParagraph")] { - if ui.button(l).clicked() { - run(app, "animation.options", json!({"textBuild": b})); + for &(id, on) in &build_rows { + let label = if id == "byParagraph" { "By Paragraph" } else { "As One Object" }; + if ui.selectable_label(on, label).clicked() { + run(app, "animation.options", json!({"textBuild": id})); + ui.close(); } } }); @@ -2410,9 +2415,30 @@ fn slide_master(app: &mut SlideApp, ui: &mut Ui) { }); } +/// The stored id is the one marked row. Every other id stays unmarked. +fn marked_options<'a>(options: &'a [&'a str], current: Option<&str>) -> Vec<(&'a str, bool)> { + options.iter().copied().map(|o| (o, current == Some(o))).collect() +} + /// One row of Transitions ▸ Effect Options: the stored option and whether it is the current one. fn transition_effect_rows(kind: &str, current: Option<&str>) -> Vec<(&'static str, bool)> { - deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == kind).map(|x| x.4).unwrap_or(&[]).iter().map(|o| (*o, current == Some(*o))).collect() + let opts = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == kind).map(|x| x.4).unwrap_or(&[]); + marked_options(opts, current) +} + +/// One row of Animations ▸ Effect Options for the selected effect. +fn animation_effect_rows(effect: &str, class: AnimClass, current: Option<&str>) -> Vec<(&'static str, bool)> { + let opts = deckcraft_model::anim::animation_info(effect, class).map(|x| x.5).unwrap_or(&[]); + marked_options(opts, current) +} + +fn text_build_id(build: deckcraft_model::anim::TextBuild) -> &'static str { + match build { + deckcraft_model::anim::TextBuild::ByParagraph => "byParagraph", + deckcraft_model::anim::TextBuild::ByWord => "byWord", + deckcraft_model::anim::TextBuild::ByLetter => "byLetter", + deckcraft_model::anim::TextBuild::AsOne => "asOne", + } } /// Gallery glyphs for the freeform tools: a wave (curve), a polygon (freeform), a squiggle (scribble). @@ -2430,15 +2456,26 @@ fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { #[cfg(test)] mod tests { - use super::transition_effect_rows; + use super::{animation_effect_rows, marked_options, text_build_id, transition_effect_rows}; + use deckcraft_model::anim::{AnimClass, TextBuild}; + + fn marked(rows: Vec<(&str, bool)>) -> Vec<&str> { + rows.into_iter().filter(|(_, on)| *on).map(|(id, _)| id).collect() + } #[test] - fn effect_options_mark_the_current_transition_option() { - let on = |kind, current| transition_effect_rows(kind, current).into_iter().map(|(_, marked)| marked).collect::>(); - assert_eq!(on("push", Some("l")), vec![false, false, true, false]); - assert_eq!(on("fade", Some("throughBlack")), vec![false, true]); - assert_eq!(on("push", Some("missing")), vec![false, false, false, false]); - assert!(on("none", None).is_empty()); - assert_eq!(transition_effect_rows("cut", Some("")).first().map(|(o, marked)| (*o, *marked)), Some(("", true))); + fn effect_options_mark_only_the_stored_choice() { + assert_eq!(marked(transition_effect_rows("push", Some("l"))), vec!["l"]); + assert_eq!(transition_effect_rows("push", Some("l")).into_iter().map(|(_, on)| on).collect::>(), vec![false, false, true, false]); + assert_eq!(marked(transition_effect_rows("fade", Some("throughBlack"))), vec!["throughBlack"]); + assert!(marked(transition_effect_rows("push", Some("missing"))).is_empty()); + assert!(transition_effect_rows("none", None).is_empty()); + assert_eq!(marked(transition_effect_rows("cut", Some(""))), vec![""]); + assert_eq!(marked(animation_effect_rows("fly", AnimClass::Entrance, Some("l"))), vec!["l"]); + assert!(marked(animation_effect_rows("fly", AnimClass::Entrance, Some("nope"))).is_empty()); + assert!(animation_effect_rows("appear", AnimClass::Entrance, Some("l")).is_empty()); + assert_eq!(marked(marked_options(&["asOne", "byParagraph"], Some(text_build_id(TextBuild::ByParagraph)))), vec!["byParagraph"]); + assert_eq!(marked(marked_options(&["asOne", "byParagraph"], Some(text_build_id(TextBuild::AsOne)))), vec!["asOne"]); + assert!(marked(marked_options(&["asOne", "byParagraph"], Some(text_build_id(TextBuild::ByWord)))).is_empty()); } }