Lift the web canvas out of in-flow so the input overlay lands on screen

A GPUI harness boots a real ElyShell, navigates to an external URL, and
asks the input_overlay's sibling canvas tracker where it laid out. On
main before this change the canvas reports

    Bounds { origin: (309, window_height - 17), size: (W - 326, content_h) }

i.e. the overlay's top edge sits at the very bottom of the visible
window. Every user click in the visible area lands above (or beside)
the overlay; the on_mouse_down + capture_any_mouse_up listeners never
even see the event because the hitbox is off-screen. Twelve commits
chased focus/coords/outcome enums on the sidecar side while every click
in the live shell hit empty space.

Root cause: in render_web_surface the rendered web image (img / loading
div / error page) was a non-absolute child of a `.relative().size_full()`
wrapper. The non-absolute child claims `size_full` block-flow height
inside that wrapper, which made the wrapper's intrinsic height
content_height + content_height. The two `.absolute().size_full()`
siblings (viewport_tracker, input_overlay) then sized against that
inflated parent and were positioned in the bottom half — exactly
content_height below where they were supposed to be.

Fix: keep the relative wrapper as the layout owner of the panel slot
(size_full, overflow_hidden, min_w_0) and put the rendered image into
an absolute `inset_0` child of its own. viewport_tracker and
input_overlay stay as absolute siblings. With the image out of in-flow
the wrapper sizes to its parent and the overlay's hitbox lands at
y = top of content area (71 in a 1080-tall window) instead of
y = window_height - 17.

GPUI test harness (`gpui_harness_tests.rs`) is the holdout set:
  - `baseline_overlay_div_receives_simulated_click` proves GPUI's
    occlude + capture_any_mouse_up primitive works under TestAppContext.
  - `baseline_overlay_with_full_listener_combo_receives_click` proves
    the exact listener combo render_input_overlay uses works in
    isolation.
  - `ely_shell_external_canvas_lays_out_inside_window` boots a real
    ElyShell, navigates, and asserts the overlay's measured bounds fit
    inside the visible window. Without the fix above, this test trips
    on bounds extending below the window bottom.

The three new store-layer tests in web_surface_tests.rs pin per-tab
isolation, zero-delta short-circuit, and resize-mid-drain decoupling
invariants the harness work flushed out.

ely_app picks up gpui's test-support feature as a dev-dependency so the
harness can use VisualTestContext + simulate_mouse_*.

cargo test --bin ely_app: 112 passed (was 108 + 4 new harness/store tests).

Remaining work (not in this commit): even with the layout fixed, the
harness shows MouseUp's capture_any_mouse_up still doesn't fire on the
ElyShell tree, while MouseDown's bubble does. Some sibling/ancestor
listener in the live shell is eating the MouseUp capture phase that
the standalone listener-combo baseline does not. Tracked separately.
This commit is contained in:
2026-05-10 17:38:10 -04:00
parent 6314e1f777
commit 840255f88c
7 changed files with 358 additions and 8 deletions
@@ -213,6 +213,120 @@ fn zero_wheel_delta_reports_zero_delta() -> Result<(), Box<dyn Error>> {
Ok(())
}
/// Pinning the per-tab isolation invariant. A click recorded against
/// tab A must not be drained by, dropped by, or overwritten by any
/// state mutation routed to tab B. The store keys every click on its
/// owning `TabId` via `PerTabSurface`, but the singleton
/// `keyboard_focus` cross-cuts tabs — so a regression that
/// accidentally entangled them (e.g. dropping A's `click_point` when
/// B took focus) would surface here.
#[test]
fn click_on_tab_a_survives_click_on_tab_b() -> Result<(), Box<dyn Error>> {
let mut store = WebSurfaceStore::new();
let tab_a = web_tab("https://example.com/a")?;
let tab_b = web_tab("https://example.com/b")?;
assert_applied(store.record_viewport_size(tab_a.id(), web_bounds(), 1.0));
assert_applied(store.record_viewport_size(tab_b.id(), web_bounds(), 1.0));
assert_applied(store.record_click_point(
tab_a.id(),
tab_a.url().as_str(),
point(px(40.0), px(40.0)),
1.0,
));
assert_applied(store.record_click_point(
tab_b.id(),
tab_b.url().as_str(),
point(px(200.0), px(200.0)),
1.0,
));
let input_a = store.take_pending_input(tab_a.id(), tab_a.url().as_str());
assert_eq!(
input_a.click_point.map(|p| (p.x(), p.y())),
Some((40, 40)),
"tab A's click must survive a subsequent click on tab B — \
per-tab surfaces are independent owners of `click_point`",
);
let input_b = store.take_pending_input(tab_b.id(), tab_b.url().as_str());
assert_eq!(
input_b.click_point.map(|p| (p.x(), p.y())),
Some((200, 200)),
"tab B's click must drain into B's pending input, not A's",
);
Ok(())
}
/// Pinning the zero-delta short-circuit's non-effect on a buffered
/// click. `record_scroll_delta` wipes `click_point` (post-scroll
/// coords would target the wrong DOM node), but the early `None`
/// return for `DroppedZeroDelta` must short-circuit *before* the wipe.
/// A future refactor that moved the wipe above the delta check would
/// silently eat clicks whenever a precision-mouse wheel reported a
/// sub-device-pixel delta.
#[test]
fn zero_wheel_delta_must_not_erase_buffered_click() -> Result<(), Box<dyn Error>> {
let mut store = WebSurfaceStore::new();
let tab = web_tab("https://example.com/form")?;
let url = tab.url().as_str();
assert_applied(store.record_viewport_size(tab.id(), web_bounds(), 1.0));
assert_applied(store.record_click_point(tab.id(), url, point(px(160.0), px(120.0)), 1.0));
assert_eq!(
store.record_scroll_delta(tab.id(), url, point(px(0.0), px(0.0)), 1.0),
WebSurfaceInputOutcome::DroppedZeroDelta,
);
let input = store.take_pending_input(tab.id(), url);
assert_eq!(
input.click_point.map(|p| (p.x(), p.y())),
Some((160, 120)),
"a zero-delta wheel event reports DroppedZeroDelta and must not \
take the `click_point` wipe path — the early return guards it",
);
Ok(())
}
/// Pinning the bounds-vs-drain decoupling. A viewport resize between
/// click and drain leaves `viewport_bounds` mutated but does not
/// touch `click_point`, `scroll_offset`, or `requested_url` — the
/// three keys the drain filter checks. The click's stored device-px
/// coordinates remain valid against the new bounds because GPUI
/// re-renders before any new click can arrive.
///
/// If a future refactor stored raw window-relative coords on
/// `click_point` and converted them at drain time, a resize between
/// record and drain would shift the result; this test would still
/// drain "something" but the coordinates would change, surfacing the
/// drift.
#[test]
fn click_survives_viewport_bounds_change_before_drain() -> Result<(), Box<dyn Error>> {
let mut store = WebSurfaceStore::new();
let tab = web_tab("https://example.com/resize")?;
let url = tab.url().as_str();
assert_applied(store.record_viewport_size(tab.id(), web_bounds(), 1.0));
assert_applied(store.record_click_point(tab.id(), url, point(px(160.0), px(120.0)), 1.0));
// Resize: first measurement is buffered (requires confirmation).
assert_eq!(
store.record_viewport_size(tab.id(), resized_once_bounds(), 1.0),
WebSurfaceInputOutcome::Buffered,
);
let input = store.take_pending_input(tab.id(), url);
assert_eq!(
input.click_point.map(|p| (p.x(), p.y())),
Some((160, 120)),
"a resize-in-progress must not steal the buffered click — \
the drain filter checks url+scroll_offset, not bounds",
);
Ok(())
}
fn web_bounds() -> Bounds<gpui::Pixels> {
Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0)))
}