E21: separate pending control completion and gesture regression phases

This commit is contained in:
skylartaylor committed 2026-10-25 05:30:25 +01:00
1 parent 19910ab41e
commit 7dbb08827e
1 file changed
+360 -242
+360 -242
View File
@@ -396,6 +396,148 @@ struct DeferredRequest {
since: web_time::Instant,
}
/// A screenshot pending in the `finish_pending` pass, with ownership of its reply.
struct PendingCapture {
slot: craft_ui::capture::CaptureSlot,
callback: Option<egui::ScreenshotCallback>,
region: Option<egui::Rect>,
reply: Sender<Reply>,
since: web_time::Instant,
geometry: Geometry,
reserved: usize,
attempts: u8,
}
impl PendingCapture {
fn into_pending(self) -> Pending {
Pending::Screenshot {
slot: self.slot,
callback: self.callback,
region: self.region,
reply: self.reply,
since: self.since,
geometry: self.geometry,
reserved: self.reserved,
attempts: self.attempts,
}
}
}
/// The state one `finish_pending` pass needs while resolving pending replies.
struct PendingFinisher {
now: web_time::Instant,
passes: u64,
current: Geometry,
in_pass: bool,
dropped: Vec<u64>,
expired: Vec<u64>,
reserved_total: usize,
}
impl PendingFinisher {
/// Answer or keep one queued-input request, preserving its one arrival deadline.
fn resolve_frames(&self, until_pass: u64, batches: Vec<u64>, since: web_time::Instant, reply: Sender<Reply>, value: Value) -> Option<Pending> {
if batches.iter().any(|id| self.expired.contains(id)) {
let _ = reply.send(Err(not_drawn("handling the input")));
return None;
}
if batches.iter().any(|id| self.dropped.contains(id)) {
let _ = reply.send(Err("the document, dialog or focus changed before the input could be applied; nothing was done".into()));
return None;
}
if self.passes >= until_pass {
let _ = reply.send(Ok(value));
return None;
}
if !self.in_pass && self.now.duration_since(since) > timeout() {
let _ = reply.send(Err(not_drawn("handling the input")));
return None;
}
Some(Pending::Frames { until_pass, batches, since, reply, value })
}
/// Give one reservation back to the running aggregate budget.
fn release_reservation(&mut self, reserved: usize) {
self.reserved_total = self.reserved_total.saturating_sub(reserved);
}
/// Resolve one screenshot request, keeping it only while its capture is still pending.
fn finish_capture(&mut self, ctx: &egui::Context, capture: PendingCapture) -> Option<Pending> {
// Completion cannot restart or bypass the admission deadline, even when the callback
// arrived before this delayed tick or a resize would retry it.
if self.now.duration_since(capture.since) > timeout() {
capture.slot.cancel();
self.release_reservation(capture.reserved);
let _ = capture.reply.send(Err(not_drawn("the screenshot")));
return None;
}
match capture.slot.take() {
Some(Ok(image)) => self.finish_captured_image(ctx, capture, image),
Some(Err(error)) => {
self.release_reservation(capture.reserved);
let _ = capture.reply.send(Err(format!("the screenshot could not be captured: {error}")));
None
}
None => Some(capture.into_pending()),
}
}
/// Finish a screenshot whose callback produced an image.
fn finish_captured_image(&mut self, ctx: &egui::Context, capture: PendingCapture, image: Arc<egui::ColorImage>) -> Option<Pending> {
if self.current.epoch != capture.geometry.epoch {
// The document/dialog changed: never save a capture of the previous context, and do
// not retry it into the new one.
self.release_reservation(capture.reserved);
let _ = capture.reply.send(Err("the document or dialog changed while the screenshot was being captured; it was not saved".into()));
return None;
}
if self.current.size != capture.geometry.size || self.current.pixels_per_point != capture.geometry.pixels_per_point {
// A late completion after a resize/DPI change is never encoded: retry once with a
// fresh slot, else report a defined error.
return self.retry_captured_image(ctx, capture);
}
self.release_reservation(capture.reserved);
let _ = capture.reply.send(screenshot_png(&image, capture.region, capture.geometry));
None
}
/// Retry one resize/DPI-stale capture with the current geometry.
fn retry_captured_image(&mut self, ctx: &egui::Context, capture: PendingCapture) -> Option<Pending> {
if capture.attempts >= 1 {
self.release_reservation(capture.reserved);
let _ = capture.reply.send(Err("the window geometry changed while the screenshot was being captured; it was not saved".into()));
return None;
}
if capture.region.as_ref().is_some_and(|region| !self.current.contains_region(*region)) {
self.release_reservation(capture.reserved);
let _ = capture.reply.send(Err("the region must lie inside the window".into()));
return None;
}
let Some(bytes) = self.current.image_bytes() else {
self.release_reservation(capture.reserved);
let _ = capture.reply.send(Err("the window has no capturable size".into()));
return None;
};
let Some(new_total) = retried_reservation(self.reserved_total, capture.reserved, bytes) else {
self.release_reservation(capture.reserved);
let _ = capture.reply.send(Err("the screenshot no longer fits the screenshot budget".into()));
return None;
};
self.reserved_total = new_total;
let (slot, callback) = craft_ui::capture::CaptureSlot::new(ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES);
Some(Pending::Screenshot {
slot,
callback: Some(callback),
region: capture.region,
reply: capture.reply,
since: capture.since,
geometry: self.current,
reserved: bytes,
attempts: capture.attempts + 1,
})
}
}
/// The app half: the request queue and requests waiting for later frames.
pub struct Control {
rx: Receiver<ControlRequest>,
@@ -615,7 +757,7 @@ impl Control {
self.pending
.iter()
.filter_map(|p| if let Pending::Screenshot { reserved, .. } = p { Some(*reserved) } else { None })
.fold(0usize, |sum, bytes| sum.saturating_add(bytes))
.fold(0usize, usize::saturating_add)
}
fn pending_screenshots(&self) -> usize {
@@ -660,6 +802,33 @@ impl Control {
fn finish_pending(&mut self, ctx: &egui::Context, host: &mut impl Host) {
let now = web_time::Instant::now();
let (dropped, expired, in_pass) = self.take_finished_shared(ctx, host, now);
let mut finisher = PendingFinisher {
now,
passes: self.passes(),
current: Geometry::current(ctx, host.capture_epoch()),
in_pass,
dropped,
expired,
reserved_total: self.reserved_bytes(),
};
let mut keep = Vec::new();
for pending in std::mem::take(&mut self.pending) {
let kept = match pending {
Pending::Frames { until_pass, batches, since, reply, value } => finisher.resolve_frames(until_pass, batches, since, reply, value),
Pending::Screenshot { slot, callback, region, reply, since, geometry, reserved, attempts } => {
finisher.finish_capture(ctx, PendingCapture { slot, callback, region, reply, since, geometry, reserved, attempts })
}
};
if let Some(pending) = kept {
keep.push(pending);
}
}
self.pending = keep;
}
/// Expire queued input and collect the batches a pending reply must react to.
fn take_finished_shared(&self, ctx: &egui::Context, host: &mut impl Host, now: web_time::Instant) -> (Vec<u64>, Vec<u64>, bool) {
let (dropped, expired, in_pass) = self
.shared
.lock()
@@ -676,95 +845,7 @@ impl Control {
if !expired.is_empty() {
ctx.request_repaint(); // Run the queued key/modifier cleanup and pointer cancellation.
}
let passes = self.passes();
let current = Geometry::current(ctx, host.capture_epoch());
let mut reserved_total = self.reserved_bytes();
let mut keep = Vec::new();
for p in self.pending.drain(..) {
match p {
Pending::Frames { until_pass, batches, since, reply, value } => {
if batches.iter().any(|id| expired.contains(id)) {
let _ = reply.send(Err(not_drawn("handling the input")));
} else if batches.iter().any(|id| dropped.contains(id)) {
let _ = reply.send(Err("the document, dialog or focus changed before the input could be applied; nothing was done".into()));
} else if passes >= until_pass {
let _ = reply.send(Ok(value));
} else if !in_pass && now.duration_since(since) > timeout() {
let _ = reply.send(Err(not_drawn("handling the input")));
} else {
keep.push(Pending::Frames { until_pass, batches, since, reply, value });
}
}
Pending::Screenshot { slot, callback, region, reply, since, geometry, reserved, attempts } => {
// Completion cannot restart or bypass the admission deadline, even when
// the callback arrived before this delayed tick or a resize would retry it.
if now.duration_since(since) > timeout() {
slot.cancel();
reserved_total = reserved_total.saturating_sub(reserved);
let _ = reply.send(Err(not_drawn("the screenshot")));
continue;
}
match slot.take() {
Some(Ok(image)) => {
if current.epoch != geometry.epoch {
// The document/dialog changed: never save a capture of the
// previous context, and do not retry it into the new one.
reserved_total = reserved_total.saturating_sub(reserved);
let _ = reply
.send(Err("the document or dialog changed while the screenshot was being captured; it was not saved".into()));
continue;
}
if current.size != geometry.size || current.pixels_per_point != geometry.pixels_per_point {
// A late completion after a resize/DPI change is never encoded:
// retry once with a fresh slot, else report a defined error.
if attempts < 1 {
if region.as_ref().is_some_and(|region| !current.contains_region(*region)) {
reserved_total = reserved_total.saturating_sub(reserved);
let _ = reply.send(Err("the region must lie inside the window".into()));
continue;
}
let Some(bytes) = current.image_bytes() else {
reserved_total = reserved_total.saturating_sub(reserved);
let _ = reply.send(Err("the window has no capturable size".into()));
continue;
};
let Some(new_total) = retried_reservation(reserved_total, reserved, bytes) else {
reserved_total = reserved_total.saturating_sub(reserved);
let _ = reply.send(Err("the screenshot no longer fits the screenshot budget".into()));
continue;
};
reserved_total = new_total;
let (new_slot, new_callback) = craft_ui::capture::CaptureSlot::new(ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES);
keep.push(Pending::Screenshot {
slot: new_slot,
callback: Some(new_callback),
region,
reply,
since,
geometry: current,
reserved: bytes,
attempts: attempts + 1,
});
} else {
reserved_total = reserved_total.saturating_sub(reserved);
let _ = reply
.send(Err("the window geometry changed while the screenshot was being captured; it was not saved".into()));
}
continue;
}
reserved_total = reserved_total.saturating_sub(reserved);
let _ = reply.send(screenshot_png(&image, region, geometry));
}
Some(Err(error)) => {
reserved_total = reserved_total.saturating_sub(reserved);
let _ = reply.send(Err(format!("the screenshot could not be captured: {error}")));
}
None => keep.push(Pending::Screenshot { slot, callback, region, reply, since, geometry, reserved, attempts }),
}
}
}
}
self.pending = keep;
(dropped, expired, in_pass)
}
fn passes(&self) -> u64 {
@@ -2033,6 +2114,7 @@ mod transport_tests {
mod lifecycle_tests {
use super::*;
use egui::Plugin as _;
use egui_kittest::Harness;
/// A request is answered after accepted frames only: a discarded layout pass is not a frame
/// the agent can see, so it must not advance the count.
@@ -2540,178 +2622,214 @@ mod lifecycle_tests {
}
}
/// The public gestures this test drives through real UI passes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Gesture {
AtomicClick,
HeldClick,
Marquee,
}
fn gesture_request(gesture: Gesture, from: egui::Pos2, to: egui::Pos2) -> (&'static str, Value) {
match gesture {
Gesture::AtomicClick => ("ui.click", json!({"x": from.x, "y": from.y})),
Gesture::HeldClick => ("ui.drag", json!({"from": [from.x, from.y], "to": [from.x, from.y], "steps": 6})),
Gesture::Marquee => ("ui.drag", json!({"from": [from.x, from.y], "to": [to.x, to.y], "steps": 6})),
}
}
fn marquee_harness() -> (Harness<'static, crate::PdfCraftApp>, ControlClient) {
let client_slot: Arc<Mutex<Option<ControlClient>>> = Arc::new(Mutex::new(None));
let slot = client_slot.clone();
let h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).with_step_dt(0.01).build_eframe(move |cc| {
let mut app = crate::PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("synthetic-form.pdf", None, include_bytes!("../tests/data/form.pdf").to_vec()).unwrap();
app.set_option("quick", "marquee-zoom").unwrap();
*slot.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx));
app
});
let client = client_slot.lock().unwrap().take().unwrap();
(h, client)
}
fn marquee_step(h: &mut Harness<'static, crate::PdfCraftApp>) {
let ctx = h.ctx.clone();
let mut raw = std::mem::take(h.input_mut());
h.state_mut().prepare_raw_input(&ctx, &mut raw);
*h.input_mut() = raw;
h.step();
}
/// The actual laid-out page rect and a short point gesture inside it.
fn gesture_target(h: &Harness<'static, crate::PdfCraftApp>) -> (egui::Pos2, egui::Pos2) {
let page = h.state().views[0].page_screen_rect(0).expect("the actual PDF page was laid out").intersect(h.state().views[0].viewport_rect());
assert!(page.width() > 100.0 && page.height() > 100.0, "a visible hit target: {page:?}");
let from = page.center();
let to = from + egui::vec2(40.0, 50.0);
assert!(page.contains(to));
(from, to)
}
/// Step until the public request has really reached egui and can still be interrupted.
fn await_gesture_admission(h: &mut Harness<'static, crate::PdfCraftApp>, gesture: Gesture) -> bool {
let mut admitted = false;
for _ in 0..8 {
marquee_step(h);
admitted = if gesture == Gesture::AtomicClick {
let shared = h.state().control.as_ref().unwrap().shared.lock().unwrap();
shared.inject.front().is_some_and(|request| request.started && request.batches.len() == 1)
} else {
h.ctx.input(|i| i.pointer.primary_down()) && (gesture != Gesture::Marquee || h.state().views[0].marquee.is_some())
};
if admitted {
break;
}
}
admitted
}
/// The request is queued exactly once, with the expected terminal batch not yet applied.
fn assert_interruptible_admission(h: &Harness<'static, crate::PdfCraftApp>, gesture: Gesture, from: egui::Pos2) {
{
let shared = h.state().control.as_ref().unwrap().shared.lock().unwrap();
assert_eq!(shared.inject.len(), 1, "only the actual public request is queued");
let pending = shared.inject.front().unwrap();
assert!(pending.started);
let terminal = pending.batches.back().expect("the terminal batch is still pending");
if gesture == Gesture::AtomicClick {
assert_eq!(pending.batches.len(), 1);
assert_eq!(terminal.len(), 2);
assert!(
matches!(terminal[0], egui::Event::PointerButton { pressed: true, button: egui::PointerButton::Primary, pos, .. } if pos == from)
);
assert!(
matches!(terminal[1], egui::Event::PointerButton { pressed: false, button: egui::PointerButton::Primary, pos, .. } if pos == from)
);
assert!(!h.ctx.input(|i| i.pointer.primary_down()), "atomic click has only hovered so far");
} else {
assert!(h.ctx.input(|i| i.pointer.primary_down()), "a real pass consumed the press");
assert_eq!(terminal.len(), 1);
assert!(matches!(terminal[0], egui::Event::PointerButton { pressed: false, button: egui::PointerButton::Primary, .. }));
}
}
assert!(!h.ctx.input(|i| i.pointer.any_released()), "the terminal release has not run");
assert!(h.ctx.drag_stopped_id().is_none());
if gesture == Gesture::Marquee {
assert!(h.ctx.dragged_id().is_some() && h.state().views[0].marquee.is_some(), "the app has a real drag preview");
} else {
assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none());
}
}
/// Expire the admission both through hidden logic and through the next input hook, then
/// assert that cancellation left no gesture, zoom or edit behind.
fn assert_timed_out_gesture(h: &mut Harness<'static, crate::PdfCraftApp>, reply: &Receiver<Reply>, hidden_logic: bool, zoom: f32, bytes: &[u8]) {
age_input(h.state_mut().control.as_mut().unwrap());
if hidden_logic {
let ctx = h.ctx.clone();
let app = h.state_mut();
let mut control = app.control.take().unwrap();
control.tick(&ctx, app);
app.control = Some(control);
assert!(reply.try_recv().unwrap().unwrap_err().contains("not being drawn"));
}
marquee_step(h);
if !hidden_logic {
assert!(reply.try_recv().unwrap().unwrap_err().contains("not being drawn"));
}
for _ in 0..2 {
marquee_step(h);
}
assert!(!h.ctx.input(|i| i.pointer.any_down() || i.pointer.any_released()));
assert!(h.ctx.dragged_id().is_none() && h.ctx.drag_stopped_id().is_none());
assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none());
assert_eq!(h.state().views[0].zoom, zoom, "canceling must not turn a drag into a zoom click");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), bytes, "no abandoned edit");
assert!(h.state().control.as_ref().unwrap().shared.lock().unwrap().inject.is_empty());
assert!(h.state().control.as_ref().unwrap().pending.is_empty());
assert!(reply.try_recv().is_err(), "the cancellation replies once");
}
/// Complete one public gesture and check its real event trace and one-step zoom.
fn complete_positive_gesture(
h: &mut Harness<'static, crate::PdfCraftApp>,
client: &ControlClient,
gesture: Gesture,
positive: Gesture,
bytes: &[u8],
) {
let (from, to) = gesture_target(h);
let zoom = h.state().views[0].zoom;
let (method, params) = gesture_request(positive, from, to);
let reply = client.send(method, params);
let mut value = None;
let mut buttons = Vec::new();
let mut positions = Vec::new();
for _ in 0..20 {
marquee_step(h);
h.ctx.input(|i| {
for event in &i.events {
match event {
egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, pos, .. } => {
buttons.push((*pressed, i.time));
positions.push(*pos);
}
egui::Event::PointerMoved(pos) => positions.push(*pos),
_ => {}
}
}
});
if let Ok(result) = reply.try_recv() {
value = Some(result.unwrap());
break;
}
}
assert!(value.is_some(), "a fresh completed request succeeds: {positive:?}, after {gesture:?}");
assert_eq!(buttons.iter().map(|(pressed, _)| *pressed).collect::<Vec<_>>(), [true, false]);
assert!(!h.ctx.input(|i| i.pointer.any_down()));
assert!(h.ctx.dragged_id().is_none());
if positive == Gesture::Marquee {
assert!(h.state().views[0].zoom > zoom, "the normal marquee rectangle zooms");
} else {
let options = h.ctx.options(|o| o.input_options);
assert!(buttons[1].1 - buttons[0].1 < options.max_click_duration, "real press/release remain a short click");
assert!(positions.iter().all(|pos| pos.distance(from) < options.max_click_dist), "real input remains stationary");
assert!((h.state().views[0].zoom - zoom * 1.5).abs() < 0.001, "a normal click zooms one step");
}
assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none());
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), bytes, "completed zooms do not edit the document");
assert!(reply.try_recv().is_err(), "successful request replies once");
}
/// Real page hit-testing, real control batches and the production marquee completion path.
/// Exercise both hidden-logic expiry and expiry first observed by the next input hook.
#[test]
fn a_timed_out_marquee_never_zooms_and_completed_clicks_and_drags_still_work() {
use egui_kittest::Harness;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Gesture {
AtomicClick,
HeldClick,
Marquee,
}
let request = |gesture, from: egui::Pos2, to: egui::Pos2| match gesture {
Gesture::AtomicClick => ("ui.click", json!({"x": from.x, "y": from.y})),
Gesture::HeldClick => ("ui.drag", json!({"from": [from.x, from.y], "to": [from.x, from.y], "steps": 6})),
Gesture::Marquee => ("ui.drag", json!({"from": [from.x, from.y], "to": [to.x, to.y], "steps": 6})),
};
for hidden_logic in [false, true] {
for gesture in [Gesture::AtomicClick, Gesture::HeldClick, Gesture::Marquee] {
let client_slot = Arc::new(Mutex::new(None));
let slot = client_slot.clone();
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).with_step_dt(0.01).build_eframe(move |cc| {
let mut app = crate::PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("synthetic-form.pdf", None, include_bytes!("../tests/data/form.pdf").to_vec()).unwrap();
app.set_option("quick", "marquee-zoom").unwrap();
*slot.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx));
app
});
let client = client_slot.lock().unwrap().take().unwrap();
let step = |h: &mut Harness<'_, crate::PdfCraftApp>| {
let ctx = h.ctx.clone();
let mut raw = std::mem::take(h.input_mut());
h.state_mut().prepare_raw_input(&ctx, &mut raw);
*h.input_mut() = raw;
h.step();
};
let target = |h: &Harness<'_, crate::PdfCraftApp>| {
let page = h.state().views[0]
.page_screen_rect(0)
.expect("the actual PDF page was laid out")
.intersect(h.state().views[0].viewport_rect());
assert!(page.width() > 100.0 && page.height() > 100.0, "a visible hit target: {page:?}");
let from = page.center();
let to = from + egui::vec2(40.0, 50.0);
assert!(page.contains(to));
(from, to)
};
let (mut h, client) = marquee_harness();
for _ in 0..5 {
step(&mut h);
marquee_step(&mut h);
}
let (from, to) = target(&h);
let (from, to) = gesture_target(&h);
let zoom = h.state().views[0].zoom;
let bytes = h.state().session.get(h.state().views[0].id).unwrap().bytes.clone();
let (method, params) = request(gesture, from, to);
let (method, params) = gesture_request(gesture, from, to);
let reply = client.send(method, params);
let mut admitted = false;
for _ in 0..8 {
step(&mut h);
admitted = if gesture == Gesture::AtomicClick {
let shared = h.state().control.as_ref().unwrap().shared.lock().unwrap();
shared.inject.front().is_some_and(|request| request.started && request.batches.len() == 1)
} else {
h.ctx.input(|i| i.pointer.primary_down()) && (gesture != Gesture::Marquee || h.state().views[0].marquee.is_some())
};
if admitted {
break;
}
}
assert!(admitted, "a real interruptible admission: {gesture:?}, hidden={hidden_logic}");
{
let shared = h.state().control.as_ref().unwrap().shared.lock().unwrap();
assert_eq!(shared.inject.len(), 1, "only the actual public request is queued");
let pending = shared.inject.front().unwrap();
assert!(pending.started);
let terminal = pending.batches.back().expect("the terminal batch is still pending");
if gesture == Gesture::AtomicClick {
assert_eq!(pending.batches.len(), 1);
assert_eq!(terminal.len(), 2);
assert!(
matches!(terminal[0], egui::Event::PointerButton { pressed: true, button: egui::PointerButton::Primary, pos, .. } if pos == from)
);
assert!(
matches!(terminal[1], egui::Event::PointerButton { pressed: false, button: egui::PointerButton::Primary, pos, .. } if pos == from)
);
assert!(!h.ctx.input(|i| i.pointer.primary_down()), "atomic click has only hovered so far");
} else {
assert!(h.ctx.input(|i| i.pointer.primary_down()), "a real pass consumed the press");
assert_eq!(terminal.len(), 1);
assert!(matches!(terminal[0], egui::Event::PointerButton { pressed: false, button: egui::PointerButton::Primary, .. }));
}
}
assert!(!h.ctx.input(|i| i.pointer.any_released()), "the terminal release has not run");
assert!(h.ctx.drag_stopped_id().is_none());
if gesture == Gesture::Marquee {
assert!(h.ctx.dragged_id().is_some() && h.state().views[0].marquee.is_some(), "the app has a real drag preview");
} else {
assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none());
}
assert!(await_gesture_admission(&mut h, gesture), "a real interruptible admission: {gesture:?}, hidden={hidden_logic}");
assert_interruptible_admission(&h, gesture, from);
assert!(reply.try_recv().is_err(), "the terminal batch has not run");
assert_eq!(h.state().views[0].zoom, zoom);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes, bytes);
age_input(h.state_mut().control.as_mut().unwrap());
if hidden_logic {
let ctx = h.ctx.clone();
let app = h.state_mut();
let mut control = app.control.take().unwrap();
control.tick(&ctx, app);
app.control = Some(control);
assert!(reply.try_recv().unwrap().unwrap_err().contains("not being drawn"));
}
step(&mut h);
if !hidden_logic {
assert!(reply.try_recv().unwrap().unwrap_err().contains("not being drawn"));
}
for _ in 0..2 {
step(&mut h);
}
assert!(!h.ctx.input(|i| i.pointer.any_down() || i.pointer.any_released()));
assert!(h.ctx.dragged_id().is_none() && h.ctx.drag_stopped_id().is_none());
assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none());
assert_eq!(h.state().views[0].zoom, zoom, "canceling must not turn a drag into a zoom click");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes, bytes, "no abandoned edit");
assert!(h.state().control.as_ref().unwrap().shared.lock().unwrap().inject.is_empty());
assert!(h.state().control.as_ref().unwrap().pending.is_empty());
assert!(reply.try_recv().is_err(), "the cancellation replies once");
assert_timed_out_gesture(&mut h, &reply, hidden_logic, zoom, &bytes);
// Complete the same public gesture, then an atomic click after held input.
// Real event traces prove the stationary drag is a valid short point click.
let positives = [gesture, Gesture::AtomicClick];
let count = if gesture == Gesture::AtomicClick { 1 } else { 2 };
for positive in positives.into_iter().take(count) {
let (from, to) = target(&h);
let zoom = h.state().views[0].zoom;
let (method, params) = request(positive, from, to);
let reply = client.send(method, params);
let mut value = None;
let mut buttons = Vec::new();
let mut positions = Vec::new();
for _ in 0..20 {
step(&mut h);
h.ctx.input(|i| {
for event in &i.events {
match event {
egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, pos, .. } => {
buttons.push((*pressed, i.time));
positions.push(*pos);
}
egui::Event::PointerMoved(pos) => positions.push(*pos),
_ => {}
}
}
});
if let Ok(result) = reply.try_recv() {
value = Some(result.unwrap());
break;
}
}
assert!(value.is_some(), "a fresh completed request succeeds: {positive:?}, after {gesture:?}");
assert_eq!(buttons.iter().map(|(pressed, _)| *pressed).collect::<Vec<_>>(), [true, false]);
assert!(!h.ctx.input(|i| i.pointer.any_down()));
assert!(h.ctx.dragged_id().is_none());
if positive == Gesture::Marquee {
assert!(h.state().views[0].zoom > zoom, "the normal marquee rectangle zooms");
} else {
let options = h.ctx.options(|o| o.input_options);
assert!(buttons[1].1 - buttons[0].1 < options.max_click_duration, "real press/release remain a short click");
assert!(positions.iter().all(|pos| pos.distance(from) < options.max_click_dist), "real input remains stationary");
assert!((h.state().views[0].zoom - zoom * 1.5).abs() < 0.001, "a normal click zooms one step");
}
assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none());
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes, bytes, "completed zooms do not edit the document");
assert!(reply.try_recv().is_err(), "successful request replies once");
complete_positive_gesture(&mut h, &client, gesture, positive, &bytes);
}
}
}