From da1d9d011673bc5e85944d6fcf3917ec44ad4167 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:31:04 +0200 Subject: [PATCH] Cap queued pointer events from ui.click/ui.drag control requests (#525) --- crates/ui-egui/src/control.rs | 90 +++++++++++++++++++++++++++-------- docs/control-protocol.md | 4 +- 2 files changed, 72 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 6f6280ef7..2ec09b875 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -133,24 +133,42 @@ fn cell_center(app: &SheetApp, c: CellRef) -> Option { Some(geo.screen_cell_rect(sh, c).center()) } -fn push_click(app: &mut SheetApp, pos: egui::Pos2, button: egui::PointerButton, count: u32, m: egui::Modifiers) { +/// Most synthetic pointer events a control client may keep queued at once (the queue drains one batch per frame). +const MAX_QUEUED_POINTER_EVENTS: usize = 10_000; + +/// Refuses a request that would push the queued pointer events past [`MAX_QUEUED_POINTER_EVENTS`]; the queue is left unchanged. +fn pointer_budget(app: &SheetApp, extra: usize) -> Result<(), String> { + let queued: usize = app.synthetic.iter().map(Vec::len).sum(); + if queued.saturating_add(extra) > MAX_QUEUED_POINTER_EVENTS { + return Err(format!("too many pointer events (limit {MAX_QUEUED_POINTER_EVENTS} queued at once)")); + } + Ok(()) +} + +fn push_click(app: &mut SheetApp, pos: egui::Pos2, button: egui::PointerButton, count: u32, m: egui::Modifiers) -> Result<(), String> { + let count = count.max(1) as usize; + pointer_budget(app, count.saturating_mul(2).saturating_add(1))?; app.synthetic.push_back(vec![egui::Event::PointerMoved(pos)]); - let mut batch = Vec::new(); - for _ in 0..count.max(1) { + let mut batch = Vec::with_capacity(count * 2); + for _ in 0..count { batch.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m }); batch.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m }); } app.synthetic.push_back(batch); + Ok(()) } -fn push_drag(app: &mut SheetApp, a: egui::Pos2, b: egui::Pos2, steps: usize, m: egui::Modifiers) { +fn push_drag(app: &mut SheetApp, a: egui::Pos2, b: egui::Pos2, steps: usize, m: egui::Modifiers) -> Result<(), String> { + let steps = steps.max(1); + pointer_budget(app, steps.saturating_add(3))?; app.synthetic.push_back(vec![egui::Event::PointerMoved(a)]); app.synthetic.push_back(vec![egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }]); - for i in 1..=steps.max(1) { - let t = i as f32 / steps.max(1) as f32; + for i in 1..=steps { + let t = i as f32 / steps as f32; app.synthetic.push_back(vec![egui::Event::PointerMoved(a + (b - a) * t)]); } app.synthetic.push_back(vec![egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }]); + Ok(()) } /// Keeps the commands whose id, label or ribbon path contains `search` (case-insensitive); `None` keeps all. @@ -312,33 +330,38 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - } "ui.click" => { let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary }; - push_click(app, egui::pos2(f("x"), f("y")), button, p.get("count").and_then(Json::as_u64).unwrap_or(1) as u32, modifiers(p)); - ok(Json::Null) + let count = p.get("count").and_then(Json::as_u64).unwrap_or(1).min(u64::from(u32::MAX)) as u32; + match push_click(app, egui::pos2(f("x"), f("y")), button, count, modifiers(p)) { + Ok(()) => ok(Json::Null), + Err(e) => err(e), + } } "ui.drag" => { - push_drag( - app, - egui::pos2(f("x"), f("y")), - egui::pos2(f("toX"), f("toY")), - p.get("steps").and_then(Json::as_u64).unwrap_or(8) as usize, - modifiers(p), - ); - ok(Json::Null) + let steps = p.get("steps").and_then(Json::as_u64).unwrap_or(8).min(u64::from(u32::MAX)) as usize; + match push_drag(app, egui::pos2(f("x"), f("y")), egui::pos2(f("toX"), f("toY")), steps, modifiers(p)) { + Ok(()) => ok(Json::Null), + Err(e) => err(e), + } } "ui.cell.click" => { let Some(c) = s("cell").and_then(CellRef::parse) else { return err("missing `cell`") }; let Some(pos) = cell_center(app, c) else { return err("no grid yet") }; let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary }; - push_click(app, pos, button, p.get("count").and_then(Json::as_u64).unwrap_or(1) as u32, modifiers(p)); - ok(json!({"x": pos.x, "y": pos.y})) + let count = p.get("count").and_then(Json::as_u64).unwrap_or(1).min(u64::from(u32::MAX)) as u32; + match push_click(app, pos, button, count, modifiers(p)) { + Ok(()) => ok(json!({"x": pos.x, "y": pos.y})), + Err(e) => err(e), + } } "ui.cell.drag" => { let (Some(a), Some(b)) = (s("from").and_then(CellRef::parse), s("to").and_then(CellRef::parse)) else { return err("missing `from`/`to`"); }; let (Some(pa), Some(pb)) = (cell_center(app, a), cell_center(app, b)) else { return err("no grid yet") }; - push_drag(app, pa, pb, 8, modifiers(p)); - ok(Json::Null) + match push_drag(app, pa, pb, 8, modifiers(p)) { + Ok(()) => ok(Json::Null), + Err(e) => err(e), + } } "ui.resize" => { ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(f("width").max(400.0), f("height").max(300.0)))); @@ -494,4 +517,31 @@ mod tests { assert!(filter_commands(sample(), Some("catalog_no_match_7391")).is_empty()); assert_eq!(filter_commands(sample(), None).len(), 3); } + + fn test_app() -> SheetApp { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + SheetApp::new(session, crate::Services::default()) + } + + #[test] + fn oversized_pointer_requests_are_refused_and_leave_the_queue_alone() { + let mut app = test_app(); + let p = egui::pos2(20.0, 20.0); + let m = egui::Modifiers::NONE; + let btn = egui::PointerButton::Primary; + assert!(push_click(&mut app, p, btn, 2, m).is_ok()); + assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), 5); + assert!(push_click(&mut app, p, btn, u32::MAX, m).is_err()); + assert!(push_drag(&mut app, p, p, usize::MAX, m).is_err()); + assert!(push_drag(&mut app, p, p, MAX_QUEUED_POINTER_EVENTS, m).is_err()); + assert_eq!(app.synthetic.len(), 2); + assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), 5); + // Already queued input counts toward the budget. + assert!(push_click(&mut app, p, btn, MAX_QUEUED_POINTER_EVENTS / 2, m).is_err()); + assert_eq!(app.synthetic.len(), 2); + // Normal requests still queue in full. + assert!(push_drag(&mut app, p, p + egui::vec2(40.0, 40.0), 3, m).is_ok()); + assert_eq!(app.synthetic.len(), 8); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a16208a38..d434fa996 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -26,8 +26,8 @@ Errors come back as `{"id": …, "ok": false, "error": "…"}`. | `sheet.read` | `{range?, sheet?, formulas?, formatted?}` | Values (or formulas / displayed text) of a range | | `cell.get` | `{cell?}` | One cell: value, formula, displayed text, style, merge, comment, link | | `ui.inspect` | | UI state: ribbon tab, editor, open dialog, Backstage page, message, grid rect, scroll, perf | -| `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points | -| `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing) | +| `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points; refused with an error if it would leave more than 10,000 pointer events queued | +| `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing); same 10,000-event queue limit | | `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing) | | `ui.edit.begin` / `ui.edit.text` / `ui.edit.commit` / `ui.edit.cancel` | `{text?}` / `{move?: down\|right\|up\|left\|none}` | Drive the cell editor | | `ui.ribbon` | `{tab}` | Switch ribbon tab |