From 5206844f92e93990981a6165b72f3bab0c4c9533 Mon Sep 17 00:00:00 2001 From: tareq Date: Fri, 30 Oct 2026 12:00:52 +0100 Subject: [PATCH 1/4] Tools bar: Alt/Option-click cycles the flyout tool slot. Matches After Effects/Photoshop: plain click keeps the shown tool; Alt-click advances the slot without opening the menu. --- crates/ui-egui/src/header.rs | 46 +++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 0e6ab3d2b..9fa98f9ac 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -102,8 +102,21 @@ fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: }; let resp = resp.on_hover_text(tip); let held = ui.data(|d| d.get_temp::(held_id)) == Some(id); + // Plain click selects the shown tool; Alt/Option-click cycles the flyout slot + // (After Effects / Photoshop parity with the Shift+letter key cycle). + let alt = ui.input(|i| i.modifiers.alt); if resp.clicked() && !held { - app.ui.tool = cur; + let pick = if slot.len() > 1 && alt { + let from = if slot.contains(&app.ui.tool) { app.ui.tool } else { cur }; + let i = slot.iter().position(|t| *t == from).unwrap_or(0); + slot[(i + 1) % slot.len()] + } else { + cur + }; + app.ui.tool = pick; + if let Some(saved) = app.ui.slot_tools.get_mut(si) { + *saved = pick; + } } // Ctrl+double-click Pan Behind: Center Anchor Point in Layer Content. if cur == Tool::PanBehind @@ -113,6 +126,7 @@ fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: { app.ui.status = e; } else if resp.double_clicked() + && !alt && let Some((id, params)) = create_on_double_click(app, cur) && let Err(e) = crate::menus::invoke(app, ui.ctx(), id, params) { @@ -901,6 +915,36 @@ mod tests { /// (#1072): with nothing selected a shape layer whose shape fills the comp, into the selected /// shape layer's Contents, a mask on a non-shape layer, a text layer from the Type tools. /// Tools that create nothing stay quiet. + /// Alt/Option-click cycles a Tools-bar flyout slot without opening the menu. + #[test] + fn alt_click_cycles_the_type_slot() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let center = |element: &crate::automation::Element| { + let [x, y, w, h] = element.rect; + egui::pos2(x + w / 2.0, y + h / 2.0) + }; + let at = center(app.auto.elements.iter().find(|w| w.id == "tools.Type").unwrap()); + let alt = egui::Modifiers { alt: true, ..Default::default() }; + let pointer = |pos, pressed, modifiers| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + app.ui.tool = Tool::Type; + app.ui.slot_tools[10] = Tool::Type; + frame(&mut app, &ctx, 1.0, vec![egui::Event::ModifiersChanged(alt), egui::Event::PointerMoved(at), pointer(at, true, alt)]); + frame(&mut app, &ctx, 1.05, vec![egui::Event::ModifiersChanged(alt), pointer(at, false, alt)]); + frame(&mut app, &ctx, 1.1, vec![]); + assert_eq!(app.ui.tool, Tool::TypeVertical); + assert_eq!(app.ui.slot_tools[10], Tool::TypeVertical); + assert!(!app.auto.elements.iter().any(|w| w.id.starts_with("tools.select.")), "Alt-click must not open the flyout"); + frame(&mut app, &ctx, 1.5, vec![egui::Event::ModifiersChanged(alt), egui::Event::PointerMoved(at), pointer(at, true, alt)]); + frame(&mut app, &ctx, 1.55, vec![egui::Event::ModifiersChanged(alt), pointer(at, false, alt)]); + frame(&mut app, &ctx, 1.6, vec![]); + assert_eq!(app.ui.tool, Tool::Type, "wraps back to Type"); + } + + #[test] fn double_clicking_creation_tools_makes_instances() { let mut s = effectcraft_engine::Session::default(); From 16d7fca162ac46f5f890b751e2e923b53c620e64 Mon Sep 17 00:00:00 2001 From: tareq Date: Fri, 30 Oct 2026 12:44:01 +0100 Subject: [PATCH 2/4] Tools bar: ignore create-on-double-click after an Alt-click. egui pairs a plain click with a prior Alt-click as a double-click; remember the previous click's Alt so create-on-double-click only runs for two plain clicks. --- crates/ui-egui/src/header.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 9fa98f9ac..3d443bdca 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -105,7 +105,12 @@ fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: // Plain click selects the shown tool; Alt/Option-click cycles the flyout slot // (After Effects / Photoshop parity with the Shift+letter key cycle). let alt = ui.input(|i| i.modifiers.alt); - if resp.clicked() && !held { + // egui's double-click ignores modifiers: remember the previous click's Alt so + // create-on-double-click only runs for two plain clicks (same as PhotoCraft). + let prev_alt_id = id.with("click-alt"); + let prev_alt = ui.data(|d| d.get_temp::(prev_alt_id)).unwrap_or(false); + let slot_click = resp.clicked() && !held; + if slot_click { let pick = if slot.len() > 1 && alt { let from = if slot.contains(&app.ui.tool) { app.ui.tool } else { cur }; let i = slot.iter().position(|t| *t == from).unwrap_or(0); @@ -127,11 +132,15 @@ fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: app.ui.status = e; } else if resp.double_clicked() && !alt + && !prev_alt && let Some((id, params)) = create_on_double_click(app, cur) && let Err(e) = crate::menus::invoke(app, ui.ctx(), id, params) { app.ui.status = e; } + if slot_click { + ui.data_mut(|d| d.insert_temp(prev_alt_id, alt)); + } if slot.len() > 1 { slot_flyout(app, ui, si, slot, &resp, held_id); } From 56974e72621c906abf97a44f1d8acd03d48c7b22 Mon Sep 17 00:00:00 2001 From: tareq Date: Fri, 30 Oct 2026 13:02:21 +0100 Subject: [PATCH 3/4] Tools bar: read click modifiers from the pointer release. Prefer the primary-release event's modifiers for Alt-cycle, and suppress create-on-double-click when either click of a double was modified. --- crates/ui-egui/src/header.rs | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 3d443bdca..c1ab3425f 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -104,11 +104,29 @@ fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: let held = ui.data(|d| d.get_temp::(held_id)) == Some(id); // Plain click selects the shown tool; Alt/Option-click cycles the flyout slot // (After Effects / Photoshop parity with the Shift+letter key cycle). - let alt = ui.input(|i| i.modifiers.alt); - // egui's double-click ignores modifiers: remember the previous click's Alt so - // create-on-double-click only runs for two plain clicks (same as PhotoCraft). - let prev_alt_id = id.with("click-alt"); - let prev_alt = ui.data(|d| d.get_temp::(prev_alt_id)).unwrap_or(false); + // Prefer the primary-release event's modifiers: Alt may already be up by the + // time this frame reads i.modifiers. + let click_mods = ui.input(|i| { + i.events + .iter() + .rev() + .find_map(|e| match e { + egui::Event::PointerButton { + button: egui::PointerButton::Primary, + pressed: false, + modifiers, + .. + } => Some(*modifiers), + _ => None, + }) + .unwrap_or(i.modifiers) + }); + let alt = click_mods.alt; + let modified = click_mods.alt || click_mods.shift || click_mods.ctrl || click_mods.command; + // egui's double-click ignores modifiers: remember whether the previous click + // was modified so create-on-double-click only runs for two plain clicks. + let prev_mod_id = id.with("click-mod"); + let prev_modified = ui.data(|d| d.get_temp::(prev_mod_id)).unwrap_or(false); let slot_click = resp.clicked() && !held; if slot_click { let pick = if slot.len() > 1 && alt { @@ -126,20 +144,20 @@ fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: // Ctrl+double-click Pan Behind: Center Anchor Point in Layer Content. if cur == Tool::PanBehind && resp.double_clicked() - && ui.input(|i| i.modifiers.command) + && click_mods.command && let Err(e) = crate::menus::invoke(app, ui.ctx(), "layer.centerAnchor", json!({})) { app.ui.status = e; } else if resp.double_clicked() - && !alt - && !prev_alt + && !modified + && !prev_modified && let Some((id, params)) = create_on_double_click(app, cur) && let Err(e) = crate::menus::invoke(app, ui.ctx(), id, params) { app.ui.status = e; } if slot_click { - ui.data_mut(|d| d.insert_temp(prev_alt_id, alt)); + ui.data_mut(|d| d.insert_temp(prev_mod_id, modified)); } if slot.len() > 1 { slot_flyout(app, ui, si, slot, &resp, held_id); From 55a6154ac7ebd82b9ecfc431691352f818bbcced Mon Sep 17 00:00:00 2001 From: tareq Date: Fri, 30 Oct 2026 13:22:38 +0100 Subject: [PATCH 4/4] Tools bar: take first-click modifiers from same-frame double-click releases. When two primary releases land on a slot in one frame, derive prev_modified from the earlier release so an Alt-then-plain pair cannot create-on-double-click. --- crates/ui-egui/src/header.rs | 60 +++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index c1ab3425f..64ab8b6a3 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -105,28 +105,36 @@ fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: // Plain click selects the shown tool; Alt/Option-click cycles the flyout slot // (After Effects / Photoshop parity with the Shift+letter key cycle). // Prefer the primary-release event's modifiers: Alt may already be up by the - // time this frame reads i.modifiers. - let click_mods = ui.input(|i| { + // time this frame reads i.modifiers. When a double-click lands as two releases + // in one frame, the earlier release is the first click. + let releases: Vec = ui.input(|i| { i.events .iter() - .rev() - .find_map(|e| match e { + .filter_map(|e| match e { egui::Event::PointerButton { + pos, button: egui::PointerButton::Primary, pressed: false, modifiers, .. - } => Some(*modifiers), + } if resp.rect.contains(*pos) => Some(*modifiers), _ => None, }) - .unwrap_or(i.modifiers) + .collect() }); + let click_mods = releases.last().copied().unwrap_or_else(|| ui.input(|i| i.modifiers)); let alt = click_mods.alt; let modified = click_mods.alt || click_mods.shift || click_mods.ctrl || click_mods.command; // egui's double-click ignores modifiers: remember whether the previous click // was modified so create-on-double-click only runs for two plain clicks. let prev_mod_id = id.with("click-mod"); - let prev_modified = ui.data(|d| d.get_temp::(prev_mod_id)).unwrap_or(false); + let prev_modified = match releases.len() { + n if n >= 2 => { + let m = releases[n - 2]; + m.alt || m.shift || m.ctrl || m.command + } + _ => ui.data(|d| d.get_temp::(prev_mod_id)).unwrap_or(false), + }; let slot_click = resp.clicked() && !held; if slot_click { let pick = if slot.len() > 1 && alt { @@ -971,6 +979,44 @@ mod tests { assert_eq!(app.ui.tool, Tool::Type, "wraps back to Type"); } + /// An Alt-click and a plain click that land as two releases in one frame must not + /// create a text layer: the first click was modified. + #[test] + fn mixed_modifier_same_frame_double_click_does_not_create() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let e = app.auto.elements.iter().find(|w| w.id == "tools.Type").expect("Type slot"); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + let alt = egui::Modifiers { alt: true, ..Default::default() }; + let plain = egui::Modifiers::default(); + let pointer = |pos, pressed, modifiers| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers, + }; + app.ui.tool = Tool::Type; + app.ui.slot_tools[10] = Tool::Type; + frame( + &mut app, + &ctx, + 1.0, + vec![ + egui::Event::PointerMoved(at), + pointer(at, true, alt), + pointer(at, false, alt), + pointer(at, true, plain), + pointer(at, false, plain), + ], + ); + assert!(app.session.active_comp().unwrap().layers.is_empty(), "Alt then plain in one frame must not create"); + assert!(app.session.state.text_edit.is_none()); + } #[test] fn double_clicking_creation_tools_makes_instances() {