From 691864e9e789fc2c5d961cf3c5b35917f1c0e00d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 06:35:11 -0400 Subject: [PATCH] M0.11: reliable control-channel input on occluded windows; Effect Controls lists fixed effects first and scrolls Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 13 +++ crates/ui-egui/src/panels/effect_controls.rs | 109 ++++++++++--------- 2 files changed, 71 insertions(+), 51 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6fe40f10..7d696e8c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -621,7 +621,19 @@ impl eframe::App for FilmcraftApp { self.fps = self.fps * 0.9 + (1.0 / dt).min(480.0) * 0.1; } self.last_time = now; + let had_synthetic = !self.synthetic.is_empty(); self.drain_control(ctx); + if !self.synthetic.is_empty() && !had_synthetic { + // Occluded macOS windows stop running `ui`; raise the window so input is processed. + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } + if !self.synthetic.is_empty() { + ctx.request_repaint(); + } else if !self.input_waiters.is_empty() { + for w in self.input_waiters.drain(..) { + let _ = w.send(json!({"ok": true, "result": null})); + } + } self.issue_screenshots(ctx); self.collect_screenshots(ctx); } @@ -636,6 +648,7 @@ impl eframe::App for FilmcraftApp { if !self.synthetic.is_empty() { ctx.request_repaint(); } else if !self.input_waiters.is_empty() { + ctx.request_repaint(); for w in self.input_waiters.drain(..) { let _ = w.send(json!({"ok": true, "result": null})); } diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index af6524f1..43108b10 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -55,59 +55,66 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let heading = if kind == filmcraft_project::TrackKind::Video { "Video" } else { "Audio" }; bui.painter().text(pos2(body.min.x + 8.0, body.min.y + 8.0), Align2::LEFT_CENTER, heading, Tokens::semibold(11.5), t.text_dim); bui.add_space(18.0); - for (idx, e) in it.effects.iter().enumerate() { - let Some(def) = e.def() else { continue }; - let key = format!("{}:{}", clip.0, idx); - let open = !app.ui.collapsed_fx.contains(&key); - let (r, resp) = bui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::click()); - if resp.hovered() { - bui.painter().rect_filled(r, 0.0, t.hover); - } - icons::paint( - bui.painter(), - Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0)), - if open { Icon::ChevronDown } else { Icon::ChevronRight }, - t.text_dim, - ); - // fx enable toggle - let fxr = Rect::from_center_size(pos2(r.min.x + 28.0, r.center().y), vec2(18.0, 14.0)); - let fxresp = bui.interact(fxr, egui::Id::new(("fxen", clip.0, idx)), Sense::click()); - bui.painter().text(fxr.center(), Align2::CENTER_CENTER, "fx", Tokens::semibold(10.5), if e.enabled { t.text } else { t.text_faint }); - if !e.enabled { - bui.painter().line_segment([fxr.left_bottom(), fxr.right_top()], Stroke::new(1.0, t.text_faint)); - } - if fxresp.clicked() { - actions.push(("effects.toggleEnabled".into(), json!({"clip": clip.0, "index": idx}))); - } - bui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, def.name, Tokens::ui(12.0), t.text); - // reset button - let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0)); - let rresp = bui.interact(rr, egui::Id::new(("fxreset", clip.0, idx)), Sense::click()).on_hover_text("Reset Effect"); - icons::paint(bui.painter(), rr.shrink(2.0), Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim }); - if rresp.clicked() { - actions.push(("effects.reset".into(), json!({"clip": clip.0, "index": idx}))); - } - app.auto.add(&format!("effectControls.effect.{}", e.effect), r, def.name); - if resp.clicked() && !fxresp.clicked() && !rresp.clicked() { - if open { - app.ui.collapsed_fx.push(key.clone()); - } else { - app.ui.collapsed_fx.retain(|k| *k != key); + // Premiere lists the fixed effects (Motion, Opacity, Time Remapping / Volume…) first. + let mut order: Vec = (0..it.effects.len()).collect(); + order.sort_by_key(|i| !it.effects[*i].def().is_some_and(|d| d.intrinsic)); + let scroll_out = egui::ScrollArea::vertical().id_salt("ec-scroll").auto_shrink([false, false]).show(&mut bui, |bui| { + for idx in order { + let e = &it.effects[idx]; + let Some(def) = e.def() else { continue }; + let key = format!("{}:{}", clip.0, idx); + let open = !app.ui.collapsed_fx.contains(&key); + let (r, resp) = bui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::click()); + if resp.hovered() { + bui.painter().rect_filled(r, 0.0, t.hover); + } + icons::paint( + bui.painter(), + Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0)), + if open { Icon::ChevronDown } else { Icon::ChevronRight }, + t.text_dim, + ); + // fx enable toggle + let fxr = Rect::from_center_size(pos2(r.min.x + 28.0, r.center().y), vec2(18.0, 14.0)); + let fxresp = bui.interact(fxr, egui::Id::new(("fxen", clip.0, idx)), Sense::click()); + bui.painter().text(fxr.center(), Align2::CENTER_CENTER, "fx", Tokens::semibold(10.5), if e.enabled { t.text } else { t.text_faint }); + if !e.enabled { + bui.painter().line_segment([fxr.left_bottom(), fxr.right_top()], Stroke::new(1.0, t.text_faint)); + } + if fxresp.clicked() { + actions.push(("effects.toggleEnabled".into(), json!({"clip": clip.0, "index": idx}))); + } + bui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, def.name, Tokens::ui(12.0), t.text); + // reset button + let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0)); + let rresp = bui.interact(rr, egui::Id::new(("fxreset", clip.0, idx)), Sense::click()).on_hover_text("Reset Effect"); + icons::paint(bui.painter(), rr.shrink(2.0), Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim }); + if rresp.clicked() { + actions.push(("effects.reset".into(), json!({"clip": clip.0, "index": idx}))); + } + app.auto.add(&format!("effectControls.effect.{}", e.effect), r, def.name); + if resp.clicked() && !fxresp.clicked() && !rresp.clicked() { + if open { + app.ui.collapsed_fx.push(key.clone()); + } else { + app.ui.collapsed_fx.retain(|k| *k != key); + } + } + resp.context_menu(|ui| { + if !def.intrinsic && ui.button("Clear").clicked() { + actions.push(("effects.remove".into(), json!({"clip": clip.0, "index": idx}))); + ui.close(); + } + }); + if !open { + continue; + } + for pd in &def.params { + param_row(app, bui, body, clip, idx, e, pd, mt_now, &mut actions, &lane, &lx, &it); } } - resp.context_menu(|ui| { - if !def.intrinsic && ui.button("Clear").clicked() { - actions.push(("effects.remove".into(), json!({"clip": clip.0, "index": idx}))); - ui.close(); - } - }); - if !open { - continue; - } - for pd in &def.params { - param_row(app, &mut bui, body, clip, idx, e, pd, mt_now, &mut actions, &lane, &lx, &it); - } - } + }); + let _ = scroll_out; // playhead in lane let px = lx(ph); ui.painter().line_segment([pos2(px, lane.min.y), pos2(px, lane.max.y)], Stroke::new(1.0, t.playhead));