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:
parent
ad1ea19bde
commit
7095a986ff
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue