fix: control channel never steals keyboard focus — UI requests and screenshots bring the window forward without activating the app (orderFrontRegardless); --control launches without activation; only ui.focus activates

Agents' ui.* requests used ViewportCommand::Focus, so the user's typing in another app landed in FilmCraft as shortcuts (stray Extract, Mark In/Out, markers…). Verified live: the frontmost app stays frontmost through launch, ui.click, ui.key and ui.screenshot, and the requests still take effect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 11:53:19 -04:00
parent ad1ea19bde
commit 7095a986ff
7 changed files with 83 additions and 8 deletions

3
Cargo.lock generated
View File

@ -1314,9 +1314,12 @@ dependencies = [
"filmcraft-ui-egui",
"log",
"muda",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"rfd",
"serde_json",
"winit",
]
[[package]]

View File

@ -22,4 +22,8 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] }
[target.'cfg(target_os = "macos")'.dependencies]
muda = "0.21"
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSProcessInfo", "NSString"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSArray"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] }
objc2 = "0.6"
# Same winit as eframe: macOS event-loop options (no app activation when driven by an agent).
winit = { version = "0.30", default-features = false }

View File

@ -17,6 +17,7 @@ mod audio;
mod control_server;
#[cfg(target_os = "macos")]
mod native_menu;
mod window_raise;
use chrono::TimeZone;
use filmcraft_engine::Session;
@ -55,7 +56,10 @@ fn main() -> eframe::Result {
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false),
.with_title_shown(false)
// Started for an agent (`--control`): open without taking the user's keyboard focus.
.with_active(control_port.is_none()),
event_loop_builder: agent_event_loop(control_port.is_some()),
..Default::default()
};
eframe::run_native(
@ -112,6 +116,9 @@ fn main() -> eframe::Result {
rfd::FileDialog::new().add_filter(filter, exts).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
app.hooks.raise_without_focus = Some(Box::new(|| {
window_raise::raise_without_focus();
}));
app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| {
rfd::FileDialog::new().add_filter(filter, exts).pick_file().map(|p| p.to_string_lossy().to_string())
}));
@ -137,3 +144,17 @@ fn main() -> eframe::Result {
fn local_offset(unix: i64) -> i32 {
chrono::Local.timestamp_opt(unix, 0).single().map(|d| d.offset().local_minus_utc()).unwrap_or(0)
}
/// Driven by an agent (`--control`): don't activate the app on launch, so the user's keyboard
/// focus stays where it is (macOS; winit activates ignoring other apps by default).
fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
#[cfg(target_os = "macos")]
if agent {
return Some(Box::new(|b| {
use winit::platform::macos::EventLoopBuilderExtMacOS;
b.with_activate_ignoring_other_apps(false);
}));
}
let _ = agent;
None
}

View File

@ -0,0 +1,28 @@
//! Bring the window forward for the control channel without taking keyboard focus.
//!
//! Occluded macOS windows stop running egui's `ui` pass, so UI-level control requests (synthetic
//! input, screenshots) need the window on screen. Activating the app for that
//! (`ViewportCommand::Focus`) steals keyboard focus from whatever the user is typing in, and their
//! keystrokes then land in FilmCraft as shortcuts. `orderFrontRegardless` orders the window to the
//! front without activating the app, so the key window (and the user's focus) stays where it is.
/// Order the app's windows to the front without activating the app (macOS). Returns whether it
/// did anything; elsewhere the caller only requests a repaint.
pub fn raise_without_focus() -> bool {
#[cfg(target_os = "macos")]
{
let Some(mtm) = objc2::MainThreadMarker::new() else { return false };
let app = objc2_app_kit::NSApplication::sharedApplication(mtm);
let windows = app.windows();
for w in windows.iter() {
if w.isVisible() || w.isMiniaturized() {
w.orderFrontRegardless();
}
}
true
}
#[cfg(not(target_os = "macos"))]
{
false
}
}

View File

@ -271,8 +271,8 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
"ui.screenshot" => {
let crop = s("panel").and_then(PanelKind::from_name).and_then(|pk| app.auto.find(&format!("panel.{}", pk.id())).map(|e| e.rect));
let crop = crop.or_else(|| s("id").and_then(|id| app.auto.find(id).map(|e| e.rect)));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
ctx.request_repaint();
// Bring the window on screen so a frame is presented, without taking keyboard focus.
app.raise_for_control(ctx);
Outcome::Screenshot { path: s("path").map(str::to_string), crop }
}
"ui.resize" => {
@ -282,6 +282,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
ok(Value::Null)
}
"ui.focus" => {
// Explicit request: activate the app and take keyboard focus.
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
ok(Value::Null)
}

View File

@ -56,6 +56,10 @@ pub struct HostHooks {
pub pick_open_file: Option<Box<dyn FnMut(&str, &[&str]) -> Option<String>>>,
/// Folder picker (Link Media search, proxy and Project Manager destinations).
pub pick_folder: Option<Box<dyn FnMut() -> Option<String>>>,
/// Bring the window on screen for control-channel UI requests *without* taking keyboard focus
/// (macOS: `orderFrontRegardless`). Without it the app only requests a repaint: it never
/// activates itself for an agent, because the user's keystrokes would land here.
pub raise_without_focus: Option<Box<dyn FnMut()>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -645,6 +649,14 @@ impl FilmcraftApp {
// ---------------------------------------------------------------- control channel
/// Make the UI pass run for a control request without stealing the user's keyboard focus.
pub(crate) fn raise_for_control(&mut self, ctx: &egui::Context) {
if let Some(raise) = self.hooks.raise_without_focus.as_mut() {
raise();
}
ctx.request_repaint();
}
fn drain_control(&mut self, ctx: &egui::Context) {
let Some(rx) = self.control_rx.take() else { return };
let now = ctx.input(|i| i.time);
@ -653,7 +665,7 @@ impl FilmcraftApp {
// UI requests need rendered frames: raise the window if `ui` hasn't run recently
// (occluded macOS windows stop running `ui`).
if req.method.starts_with("ui.") && now - self.last_ui_time > 0.25 {
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
self.raise_for_control(ctx);
}
reqs.push((req, now + 3.0));
}
@ -948,8 +960,9 @@ impl eframe::App for FilmcraftApp {
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);
// Occluded macOS windows stop running `ui`; bring the window forward (without taking
// keyboard focus) so the input is processed.
self.raise_for_control(ctx);
}
if !self.synthetic.is_empty() {
ctx.request_repaint();

View File

@ -28,7 +28,12 @@ Methods (handlers in `crates/ui-egui/src/control.rs`):
| `ui.timeline.hit` / `ui.timeline.locate` | `{x,y}` / `{clip, edge?}` | timeline hit-testing |
| `ui.playback` | `{action: play|stop|toggle, speed?}` | |
| `ui.screenshot` | `{path?, panel?}` | PNG of the window or one panel; fails after 10 s when no frame is presented (window hidden, display asleep) |
| `ui.resize`, `ui.focus`, `app.quit` | | |
| `ui.resize`, `ui.focus`, `app.quit` | | `ui.focus` is the only request that activates the app and takes keyboard focus |
**Focus.** Driving the app never steals the user's keyboard. Started with `--control`, the app opens
without activating itself. UI requests and screenshots that need a rendered frame bring the window
forward without making it key (macOS `orderFrontRegardless`), so the user's typing keeps going to
the app they are in. Only an explicit `ui.focus` activates FilmCraft.
Element ids are stable, e.g. `timeline.clip.<id>`, `timeline.track.V1.lock`, `tools.Razor`,
`project.item.<id>`, `effects.item.gaussian_blur`, `panel.tab.Timeline`, `program.transport.playback.toggle`.