M3.9: control channel counts real egui passes (eframe runs logic without a pass while the window is hidden): clicks/keys/screenshots fail after 5 s with a clear message and dropped input instead of false success
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
669196a67e
commit
ebfe058a9c
|
|
@ -32,6 +32,30 @@ use serde_json::{Value, json};
|
|||
|
||||
pub type Reply = Result<Value, String>;
|
||||
|
||||
/// Seconds to wait for frames (input to be handled, a screenshot to be taken) before reporting
|
||||
/// that the window is not being drawn. While a window is hidden, minimized or fully covered,
|
||||
/// eframe keeps calling `logic` but runs no egui pass, so input and screenshots wait.
|
||||
const NOT_DRAWN_TIMEOUT: f64 = 5.0;
|
||||
|
||||
/// Wall-clock seconds (egui's own clock stops while the window is not drawn).
|
||||
fn now_secs() -> f64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
||||
START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
js_sys::Date::now() / 1000.0
|
||||
}
|
||||
}
|
||||
|
||||
fn not_drawn(what: &str) -> String {
|
||||
format!(
|
||||
"{what} did not happen within {NOT_DRAWN_TIMEOUT:.0} s: the window is not being drawn (it may be hidden, minimized or covered); bring it to the front and retry"
|
||||
)
|
||||
}
|
||||
|
||||
/// One request from a client, answered on `reply`.
|
||||
pub struct ControlRequest {
|
||||
pub method: String,
|
||||
|
|
@ -67,6 +91,8 @@ struct Shared {
|
|||
focus: Option<NodeId>,
|
||||
/// Event batches to inject, one batch per frame.
|
||||
inject: VecDeque<Vec<egui::Event>>,
|
||||
/// Real egui passes so far (counted in `output_hook`, which `logic`-only calls never reach).
|
||||
passes: u64,
|
||||
}
|
||||
|
||||
/// The egui plugin half of the control channel.
|
||||
|
|
@ -90,8 +116,9 @@ impl egui::Plugin for ControlPlugin {
|
|||
}
|
||||
|
||||
fn output_hook(&mut self, _ctx: &egui::Context, output: &mut egui::FullOutput) {
|
||||
let Some(update) = &output.platform_output.accesskit_update else { return };
|
||||
let Ok(mut s) = self.shared.lock() else { return };
|
||||
s.passes += 1;
|
||||
let Some(update) = &output.platform_output.accesskit_update else { return };
|
||||
s.nodes = update.nodes.iter().cloned().collect();
|
||||
s.root = update.tree.as_ref().map(|t| t.root);
|
||||
s.focus = Some(update.focus);
|
||||
|
|
@ -101,7 +128,9 @@ impl egui::Plugin for ControlPlugin {
|
|||
enum Pending {
|
||||
/// Answer after `frames` more frames (the injected input has been handled by then).
|
||||
Frames {
|
||||
frames: u32,
|
||||
/// Answer once this many egui passes have run (the injected input has been handled).
|
||||
until_pass: u64,
|
||||
since: f64,
|
||||
reply: Sender<Reply>,
|
||||
value: Value,
|
||||
},
|
||||
|
|
@ -109,6 +138,8 @@ enum Pending {
|
|||
tag: u64,
|
||||
region: Option<egui::Rect>,
|
||||
reply: Sender<Reply>,
|
||||
/// When it was requested (egui time, seconds).
|
||||
since: f64,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -147,12 +178,15 @@ impl Control {
|
|||
Handled::Now(r) => {
|
||||
let _ = req.reply.send(r);
|
||||
}
|
||||
Handled::AfterFrames(frames, value) => self.pending.push(Pending::Frames { frames, reply: req.reply, value }),
|
||||
Handled::AfterFrames(frames, value) => {
|
||||
let until_pass = self.passes() + u64::from(frames);
|
||||
self.pending.push(Pending::Frames { until_pass, since: now_secs(), reply: req.reply, value });
|
||||
}
|
||||
Handled::Screenshot(region) => {
|
||||
let tag = self.next_tag;
|
||||
self.next_tag += 1;
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(tag)));
|
||||
self.pending.push(Pending::Screenshot { tag, region, reply: req.reply });
|
||||
self.pending.push(Pending::Screenshot { tag, region, reply: req.reply, since: now_secs() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -175,24 +209,42 @@ impl Control {
|
|||
.collect()
|
||||
});
|
||||
let ppp = ctx.pixels_per_point();
|
||||
let now = now_secs();
|
||||
let passes = self.passes();
|
||||
let mut keep = Vec::new();
|
||||
for p in self.pending.drain(..) {
|
||||
match p {
|
||||
Pending::Frames { frames: 0, reply, value } => {
|
||||
Pending::Frames { until_pass, reply, value, .. } if passes >= until_pass => {
|
||||
let _ = reply.send(Ok(value));
|
||||
}
|
||||
Pending::Frames { frames, reply, value } => keep.push(Pending::Frames { frames: frames - 1, reply, value }),
|
||||
Pending::Screenshot { tag, region, reply } => match shots.iter().find(|(t, _)| *t == tag) {
|
||||
Pending::Frames { since, reply, .. } if now - since > NOT_DRAWN_TIMEOUT => {
|
||||
// The agent is told it failed, so it must not happen later either.
|
||||
if let Ok(mut sh) = self.shared.lock() {
|
||||
sh.inject.clear();
|
||||
}
|
||||
let _ = reply.send(Err(not_drawn("handling the input")));
|
||||
}
|
||||
p @ Pending::Frames { .. } => keep.push(p),
|
||||
Pending::Screenshot { tag, region, reply, since } => match shots.iter().find(|(t, _)| *t == tag) {
|
||||
Some((_, image)) => {
|
||||
let _ = reply.send(screenshot_png(image, region, ppp));
|
||||
}
|
||||
None => keep.push(Pending::Screenshot { tag, region, reply }),
|
||||
// The platform skips painting windows that are hidden, minimized or fully
|
||||
// covered, so no screenshot ever arrives: say so instead of hanging.
|
||||
None if now - since > NOT_DRAWN_TIMEOUT => {
|
||||
let _ = reply.send(Err(not_drawn("the screenshot")));
|
||||
}
|
||||
None => keep.push(Pending::Screenshot { tag, region, reply, since }),
|
||||
},
|
||||
}
|
||||
}
|
||||
self.pending = keep;
|
||||
}
|
||||
|
||||
fn passes(&self) -> u64 {
|
||||
self.shared.lock().map(|s| s.passes).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn inject(&self, batches: Vec<Vec<egui::Event>>) -> u32 {
|
||||
let n = batches.len() as u32;
|
||||
if let Ok(mut s) = self.shared.lock() {
|
||||
|
|
|
|||
|
|
@ -195,3 +195,35 @@ fn incomplete_entries_are_ignored_and_cleaned_up() {
|
|||
assert!(s.list().is_empty());
|
||||
assert_eq!(files_in(&s), 0);
|
||||
}
|
||||
|
||||
/// Through the control channel, the reply to a click comes after the click's effects: state read
|
||||
/// right after it already reflects them (found driving the live app).
|
||||
#[test]
|
||||
fn control_click_effects_are_visible_when_the_reply_arrives() {
|
||||
let s = store("control");
|
||||
crashed_session(&s, fixture(2), None, None);
|
||||
let slot: std::sync::Arc<std::sync::Mutex<Option<printcraft_ui_egui::control::ControlClient>>> = Default::default();
|
||||
let (slot2, s2) = (slot.clone(), s.clone());
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |cc| {
|
||||
let mut app = PrintCraftApp::new();
|
||||
*slot2.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx));
|
||||
app.enable_recovery(s2);
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
let c = slot.lock().unwrap().take().unwrap();
|
||||
let call = |h: &mut Harness<'static, PrintCraftApp>, m: &str, p: serde_json::Value| {
|
||||
let rx = c.send(m, p);
|
||||
for _ in 0..30 {
|
||||
h.step();
|
||||
if let Ok(r) = rx.try_recv() {
|
||||
return r.unwrap();
|
||||
}
|
||||
}
|
||||
panic!("no reply to {m}");
|
||||
};
|
||||
assert_eq!(call(&mut h, "ui.state", serde_json::json!({}))["dialog"], "Recovery");
|
||||
call(&mut h, "ui.click", serde_json::json!({ "label": "Discard" }));
|
||||
assert_eq!(files_in(&s), 0, "discarded by the time the click is answered");
|
||||
assert_eq!(call(&mut h, "ui.state", serde_json::json!({}))["dialog"], serde_json::Value::Null);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue