diff --git a/AGENTS.md b/AGENTS.md index c2983ec..2c25712 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -595,10 +595,18 @@ These are the things we've burned ourselves on. Following them isn't optional. can route a callback from an old native tree into the current screen. A sender must also copy the tag into its delivery environment while holding that mutex; the table's `tag_env` may be freed as soon as the lock is released. - Change events and animation-delayed dismissals may cross one render when - both retained registrations have identical PID and tag identity; taps and - gestures stay generation-strict. Invalidate the building table's generation - at `clear_taps` so stale lookup never observes a partially rebuilt table. + Change events, animation-delayed dismissals, press in/out, and taps on a + node that also declares `on_press_in` / `on_press_out` may cross renders + when both retained registrations have identical PID and tag identity; plain + taps and the other gestures stay generation-strict. A plain tap must stay + strict because a positional tag (Mob.List's `{:select, id, index}`) from an + old tree would select a different row after the list changes. A press + node re-renders between a finger's down and up (`on_press_in`), so its tap + goes through the tolerant `mob_send_press_tap`. A press_out's routing is + copied at touch-down and sent from the copy, so the pair survives any number + of renders (`decisions/2026-10-03-press-in-out-and-held-press.md`). + Invalidate the building table's generation at `clear_taps` so stale lookup + never observes a partially rebuilt table. 4. **TDD discipline in mob_dev.** Every new public function gets a test. `mob_dev/AGENTS.md` makes this explicit. Don't bypass — the tests are how we diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aa1054..7bce526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ Full module documentation: [hexdocs.pm/mob](https://hexdocs.pm/mob). --- +## [Unreleased] + +### Added +- **`on_press_in` / `on_press_out` on any node** (MOB-380): `{:press_in, tag}` + when a finger goes down on the node, `{:press_out, tag}` when it lifts or the + touch is cancelled. Always paired, even across the re-render a press usually + causes: native resolves both handles at touch-down and keeps press_out's + routing until the lift. They observe without consuming, so `on_tap`, + `on_long_press` and an ancestor's scrolling keep working on the same node, + `button` included. iOS (SwiftUI) and Android (Compose; the observer lives in + the generated bridge, so it needs `mob_new` 0.6.5 or newer). For + hold-to-talk and anything else keyed on a held finger. The `on_tap` of a + node that declares either prop goes through the new `mob_send_press_tap`, + which accepts a handle from the render before when its slot still holds the + same pid and tag, so the re-render a press causes no longer drops the tap. + Plain taps stay generation-strict. +- **`Mob.Test` can hold a press** (MOB-380). `press_down_xy/4`, + `press_move_xy/3`, `press_up_xy/3` and `hold_xy/4` put a real finger on an + Android app's window and keep it there across calls, so a Compose + `detectTapGestures` `onPress` sits in `tryAwaitRelease()` while the test + reads state (`adb input swipe x y x y 3000` does not produce that on every + device). A held press auto-cancels after `:max_hold_ms` (30 s); other + synthetic gestures refuse while it is down. New NIFs + `mob_nif:press_down_xy/3`, `press_move_xy/2`, `press_up_xy/2`, reported by + `capabilities/1`; iOS answers `{:error, :not_supported}`. By-tag + `press_in/2`, `press_out/2` and `hold/3` drive a screen on every platform. + ## [0.9.11] - 2026-10-03 ### Upgrading diff --git a/android/jni/mob_beam.h b/android/jni/mob_beam.h index 320289c..4199df0 100644 --- a/android/jni/mob_beam.h +++ b/android/jni/mob_beam.h @@ -16,6 +16,12 @@ void mob_ui_cache_class(JNIEnv *env, const char *bridge_class); // Called from the app's Java_..._MobBridge_nativeSendTap JNI stub. void mob_send_tap(int handle); +// Send a tap from a node that also declares on_press_in / on_press_out +// (MOB-380). Identity-tolerant: a handle from the previous render still +// resolves when its slot holds the same pid and tag (press_in usually +// re-renders before the lift). Called from Java_..._MobBridge_nativeSendPressTap. +void mob_send_press_tap(int handle); + // Send a {:dismiss, tag} event — the shape Mob.UI.sheet/2 documents for // :on_dismiss, matching iOS. Called from the app's // Java_..._MobBridge_nativeSendDismiss JNI stub. @@ -53,6 +59,12 @@ void mob_send_swipe_down(int handle); // Direction-aware: emits {:swipe, tag, direction_atom} where direction is // "left" | "right" | "up" | "down". void mob_send_swipe_with_direction(int handle, const char *direction); +// on_press_in / on_press_out (MOB-380). Begin resolves both handles at +// touch-down (-1 for one the node doesn't declare), snapshots press_out's +// routing, then sends {:press_in, tag}; returns the token for mob_press_end, or +// -1 when there is no press_out to deliver. End sends {:press_out, tag} once. +int mob_press_begin(int in_handle, int out_handle); +void mob_press_end(int token); // ── Batch 5 Tier 1: high-frequency scroll/drag/pinch/rotate/pointer ───── // Throttling and delta-thresholding are applied native-side BEFORE these diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig index 112709b..a76c649 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -292,6 +292,10 @@ pub const BridgeMethods = extern struct { render_stats_enable: jni.JMethodID = null, long_press_xy: jni.JMethodID = null, swipe_xy: jni.JMethodID = null, + // MOB-380 held press: pressDownXy(FFJ)I, pressMoveXy(FF)I, pressUpXy(FF)I. + press_down_xy: jni.JMethodID = null, + press_move_xy: jni.JMethodID = null, + press_up_xy: jni.JMethodID = null, screenshot: jni.JMethodID = null, scroll_info: jni.JMethodID = null, scroll_to: jni.JMethodID = null, @@ -679,7 +683,8 @@ export fn nif_capabilities( erts.atom(env, "ax_action"), erts.atom(env, "element_frames"), erts.atom(env, "scroll_info"), erts.atom(env, "scroll_to"), erts.atom(env, "sample_region"), erts.atom(env, "screenshot"), - erts.atom(env, "native_stats"), + erts.atom(env, "native_stats"), erts.atom(env, "press_down_xy"), + erts.atom(env, "press_move_xy"), erts.atom(env, "press_up_xy"), }; const vals = [_]erts.ERL_NIF_TERM{ boolAtom(env, Bridge.ui_view_tree != null), @@ -711,6 +716,9 @@ export fn nif_capabilities( // for the pair; renderStatsEnable without renderStats would be an app // that half-applied a template update. boolAtom(env, Bridge.render_stats != null), + boolAtom(env, Bridge.press_down_xy != null), + boolAtom(env, Bridge.press_move_xy != null), + boolAtom(env, Bridge.press_up_xy != null), }; return erts.makeMap(env, &keys, &vals) orelse erts.atom(env, "error"); } @@ -1017,6 +1025,90 @@ export fn nif_swipe_xy( return if (ok != 0) erts.ok(env) else errorAtom(env, "dispatch_failed"); } +// ── Held press (MOB-380) ───────────────────────────────────────────────── +// press_down_xy/3, press_move_xy/2, press_up_xy/2: a finger that stays down +// across calls. The bridge answers with a code rather than a boolean, since +// "a press is already held" and "no press is held" are different mistakes +// from "nothing took the touch". Codes mirror PRESS_* in MobBridge.kt. +fn pressResult(env: ?*erts.ErlNifEnv, code: jni.JInt) erts.ERL_NIF_TERM { + return switch (code) { + 0 => erts.ok(env), + 1 => errorAtom(env, "dispatch_failed"), + 2 => errorAtom(env, "already_pressed"), + 3 => errorAtom(env, "no_window"), + 4 => errorAtom(env, "not_pressed"), + 5 => errorAtom(env, "window_gone"), + 6 => errorAtom(env, "timeout"), + else => errorAtom(env, "unknown"), + }; +} + +export fn nif_press_down_xy( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.press_down_xy == null) return notLoaded(env); + const x = erts.getNumber(env, argv[0]) orelse return erts.badarg(env); + const y = erts.getNumber(env, argv[1]) orelse return erts.badarg(env); + var max_hold: c_int = 0; + if (erts.enif_get_int(env, argv[2], &max_hold) == 0 or max_hold <= 0) return erts.badarg(env); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const code = jenv.*.CallStaticIntMethod.?( + jenv, + Bridge.cls, + Bridge.press_down_xy, + @as(f32, @floatCast(x)), + @as(f32, @floatCast(y)), + @as(i64, @intCast(max_hold)), + ); + detachIfAttached(attached); + return pressResult(env, code); +} + +fn pressAt( + env: ?*erts.ErlNifEnv, + argv: [*]const erts.ERL_NIF_TERM, + method: jni.JMethodID, +) erts.ERL_NIF_TERM { + if (method == null) return notLoaded(env); + const x = erts.getNumber(env, argv[0]) orelse return erts.badarg(env); + const y = erts.getNumber(env, argv[1]) orelse return erts.badarg(env); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const code = jenv.*.CallStaticIntMethod.?( + jenv, + Bridge.cls, + method, + @as(f32, @floatCast(x)), + @as(f32, @floatCast(y)), + ); + detachIfAttached(attached); + return pressResult(env, code); +} + +export fn nif_press_move_xy( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + return pressAt(env, argv, Bridge.press_move_xy); +} + +export fn nif_press_up_xy( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + return pressAt(env, argv, Bridge.press_up_xy); +} + // ── Handle registries (Phase 6b iter 3c) ───────────────────────────────── // // Two pools of per-widget routing slots. The tap registry is cleared every @@ -1152,6 +1244,7 @@ var component_mutex: ?*erts.ErlNifMutex = null; pub export fn mob_nif_init_state() callconv(.c) c_int { tap_mutex = erts.enif_mutex_create("mob_tap_mutex") orelse return -1; component_mutex = erts.enif_mutex_create("mob_component_mutex") orelse return -1; + press_mutex = erts.enif_mutex_create("mob_press_mutex") orelse return -1; return 0; } @@ -1280,11 +1373,24 @@ fn sendChange(handle: c_int, value_term: erts.ERL_NIF_TERM) void { // ── Tap + change senders ──────────────────────────────────────────────── /// Called from beam_jni.c's `nativeSendTap` JNI stub. Sends `{:tap, tag}` -/// to the pid registered for `handle`. +/// to the pid registered for `handle`. Generation-strict: a handle from an +/// earlier render is dropped, so a positional tag (Mob.List's +/// `{:select, id, index}`) never reaches a row that moved under it. pub export fn mob_send_tap(handle: c_int) callconv(.c) void { sendEvent(handle, "tap"); } +/// Called from beam_jni.c's `nativeSendPressTap` JNI stub, which the bridge +/// uses instead of `nativeSendTap` for a node that also declares on_press_in +/// or on_press_out (MOB-380). Such a node re-renders between the finger's +/// down and up (press_in usually changes the screen), so its lift fires a +/// handle from the previous render. Identity-tolerant, like change events: +/// it still resolves when the slot holds the same pid and tag. Plain taps +/// stay on the strict `mob_send_tap`. +pub export fn mob_send_press_tap(handle: c_int) callconv(.c) void { + sendIdentityEvent(handle, "tap"); +} + /// Called from beam_jni.c's `nativeSendDismiss` JNI stub. Sends /// `{:dismiss, tag}` — the shape `Mob.UI.sheet/2` documents for `:on_dismiss` /// and the one iOS has always delivered (`mob_send_dismiss` in ios/mob_nif.m). @@ -1389,6 +1495,88 @@ pub export fn mob_send_swipe_down(handle: c_int) callconv(.c) void { sendEvent(handle, "swipe_down"); } +// ── Press in / out (MOB-380) ───────────────────────────────────────────── +// A press_out has to reach the screen however many renders happen while the +// finger is down, and the press itself usually causes one ("listening"). So +// its routing is resolved at touch-down and kept here until the lift, rather +// than looked up again from a handle that may be stale by then. Both handles +// resolve identity-tolerantly, like change events: same slot, pid and tag +// across renders. Ten slots: one per finger on a different node. +const MAX_PRESSES: usize = 10; +const PressSlot = struct { + env: ?*erts.ErlNifEnv = null, + pid: erts.ErlNifPid = undefined, + tag: erts.ERL_NIF_TERM = 0, +}; +var press_slots: [MAX_PRESSES]PressSlot = @splat(PressSlot{}); +var press_mutex: ?*erts.ErlNifMutex = null; + +fn takePress(token: c_int) ?PressSlot { + if (token < 0 or token >= MAX_PRESSES) return null; + erts.enif_mutex_lock(press_mutex); + defer erts.enif_mutex_unlock(press_mutex); + const slot = press_slots[@intCast(token)]; + if (slot.env == null) return null; + press_slots[@intCast(token)] = .{}; + return slot; +} + +/// Called at touch-down with the node's on_press_in and on_press_out handles +/// (-1 for one it doesn't declare). Snapshots press_out first, then sends +/// `{:press_in, tag}`; when a declared handle can't be resolved nothing is +/// sent, so a press_out never arrives without its press_in. Returns the token +/// for mob_press_end, or -1 when there is no press_out to deliver. +pub export fn mob_press_begin(in_handle: c_int, out_handle: c_int) callconv(.c) c_int { + var token: c_int = -1; + if (out_handle >= 0) { + const keep = erts.enif_alloc_env() orelse return -1; + const snap = snapChangeTap(out_handle, keep) orelse { + erts.enif_free_env(keep); + return -1; + }; + erts.enif_mutex_lock(press_mutex); + for (&press_slots, 0..) |*slot, i| { + if (slot.env == null) { + slot.* = .{ .env = keep, .pid = snap.pid, .tag = snap.tag }; + token = @intCast(i); + break; + } + } + erts.enif_mutex_unlock(press_mutex); + if (token < 0) { + erts.enif_free_env(keep); + logd_nif("press slots exhausted; dropping press on handle {d}", .{out_handle}); + return -1; + } + } + + if (in_handle >= 0) { + const env = erts.enif_alloc_env() orelse { + if (takePress(token)) |slot| erts.enif_free_env(slot.env); + return -1; + }; + defer erts.enif_free_env(env); + const snap = snapChangeTap(in_handle, env) orelse { + if (takePress(token)) |slot| erts.enif_free_env(slot.env); + return -1; + }; + const msg = erts.makeTuple(env, .{ erts.enif_make_atom(env, "press_in"), snap.tag }); + var pid = snap.pid; + _ = erts.enif_send(null, &pid, env, msg); + } + return token; +} + +/// The finger lifted or the gesture was cancelled: `{:press_out, tag}` from +/// the touch-down snapshot. A token is delivered at most once. +pub export fn mob_press_end(token: c_int) callconv(.c) void { + const slot = takePress(token) orelse return; + defer erts.enif_free_env(slot.env); + const msg = erts.makeTuple(slot.env, .{ erts.enif_make_atom(slot.env, "press_out"), slot.tag }); + var pid = slot.pid; + _ = erts.enif_send(null, &pid, slot.env, msg); +} + pub export fn mob_send_swipe_with_direction(handle: c_int, direction: [*:0]const u8) callconv(.c) void { const env = erts.enif_alloc_env() orelse return; defer erts.enif_free_env(env); @@ -5146,6 +5334,9 @@ fn nifLoad(env: ?*erts.ErlNifEnv, priv: *?*anyopaque, info: erts.ERL_NIF_TERM) c cacheOptional(jenv, "clearText", "()Z", &Bridge.clear_text); cacheOptional(jenv, "longPressXy", "(FFJ)Z", &Bridge.long_press_xy); cacheOptional(jenv, "swipeXy", "(FFFF)Z", &Bridge.swipe_xy); + cacheOptional(jenv, "pressDownXy", "(FFJ)I", &Bridge.press_down_xy); + cacheOptional(jenv, "pressMoveXy", "(FF)I", &Bridge.press_move_xy); + cacheOptional(jenv, "pressUpXy", "(FF)I", &Bridge.press_up_xy); cacheOptional(jenv, "renderStats", "()Ljava/lang/String;", &Bridge.render_stats); cacheOptional(jenv, "renderStatsEnable", "(Z)Z", &Bridge.render_stats_enable); @@ -5251,6 +5442,11 @@ const nif_funcs = [_]erts.ErlNifFunc{ .{ .name = "clear_text", .arity = 0, .fptr = nif_clear_text, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND }, .{ .name = "long_press_xy", .arity = 3, .fptr = nif_long_press_xy, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND }, .{ .name = "swipe_xy", .arity = 4, .fptr = nif_swipe_xy, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND }, + // A held press dispatches one MotionEvent per call, but each still waits on + // the UI thread (and on another gesture holding the gesture mutex). + .{ .name = "press_down_xy", .arity = 3, .fptr = nif_press_down_xy, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND }, + .{ .name = "press_move_xy", .arity = 2, .fptr = nif_press_move_xy, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND }, + .{ .name = "press_up_xy", .arity = 2, .fptr = nif_press_up_xy, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND }, .{ .name = "native_stats", .arity = 0, .fptr = nif_native_stats, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND }, .{ .name = "native_stats_enable", .arity = 1, .fptr = nif_native_stats_enable, .flags = 0 }, .{ .name = "screenshot", .arity = 3, .fptr = nif_screenshot, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND }, diff --git a/decisions/2026-10-03-press-in-out-and-held-press.md b/decisions/2026-10-03-press-in-out-and-held-press.md new file mode 100644 index 0000000..ecd5b30 --- /dev/null +++ b/decisions/2026-10-03-press-in-out-and-held-press.md @@ -0,0 +1,96 @@ +# Press in / out props, and a held press agents can drive + +- Date: 2026-10-03 +- Status: accepted + +## Context + +Hold-to-talk (MOB-380) needs to know when a finger goes down on a node and +when it comes up. Mob had `on_tap` and `on_long_press`, both of which fire +once, at the end of a gesture, so Operator built its mic as an app-local +Compose `native_view` around `detectTapGestures(onPress = { tryAwaitRelease() })`. + +Agents could not test any of it. On the Moto G 2021, `adb shell input swipe +x y x y 3000` and `input motionevent DOWN/UP` never reached Compose as a held +press, and `Mob.Test.long_press_xy/4` holds and releases inside one call, so +nothing can look at the app while the finger is down. + +## Decision + +1. **`on_press_in` / `on_press_out`**, named after React Native's + `onPressIn` / `onPressOut` rather than `on_press` / `on_release`, because + "press" alone reads as a tap in mob's existing vocabulary. Messages are + `{:press_in, tag}` / `{:press_out, tag}`, with no payload: a handler that + wants the hold length measures it on the BEAM, where both events cross the + same channel. Discrete native input (`Mob.Event.NativeInput`). + +2. **They observe without consuming, on every node type**, `button` included + (unlike `on_long_press`, which follows iOS's `mobGestures` node set). + Android: a `pointerInput` on the Initial pass that never consumes, ending + when every pointer is up (Compose reports a cancel that way) or in a + `finally` when the detector is torn down. iOS 18+: a UIKit recognizer + (`UIGestureRecognizerRepresentable`) that never recognizes, recognizes + simultaneously with everything and neither cancels nor delays touches, so + the press ends in `touchesEnded` / `touchesCancelled`. A simultaneous + `DragGesture(minimumDistance: 0)` was tried first and rejected: a swipe + starting on the node no longer scrolled its ScrollView, and a quick tap + delivered `tap` before `press_in`. iOS 17 falls back to that DragGesture + behind a `@GestureState` (resets on cancel), accepting both flaws. + +3. **Always paired, so routing is snapshotted at touch-down.** A press almost + always causes a re-render ("listening"), and `set_root` commits the new + handle generation on the BEAM thread before the UI thread has the new + tree. A press_out looked up from its handle at lift time can therefore be + rejected as stale, and the app is left believing the finger is still + down. So at touch-down native resolves both handles identity-tolerantly + (same slot, pid and tag, like change events), copies press_out's pid and + tag into a slot of its own, and only then sends press_in; at the lift it + sends press_out from that copy, once. If a declared handle can't be + resolved, neither is sent. This is a deliberate exception to "plain taps + and the other gestures stay generation-strict" (AGENTS.md pre-empt rule + 3): the pair is one gesture whose second half must survive renders. + + **A press node's tap is identity-tolerant too.** On the emulator, 1–6 of + 20 `tap_xy` taps on a box whose `on_press_in` changed its label were lost + (logcat: "rejected stale event handle"): the tap's handle belonged to the + tree before the press's render. For a node that declares `on_press_in` or + `on_press_out`, the tap goes through `mob_send_press_tap`, which resolves + like a change event: the slot still holds the same pid and tag, so the + `{:tap, tag}` delivered is the message the tapped node declared. The + bridge picks it: Android's `sendTapFor` in `RenderNodeInner` and + `MobButton`, iOS's `on_tap` deserialiser. + Plain taps keep the strict `mob_send_tap`: a first cut made every tap + tolerant, and review found that a stale positional tag such as Mob.List's + `{:select, id, index}` would then select whichever row the list had moved + into that index, where the strict lookup drops it. The other gestures stay + strict. + +4. **A held press in `Mob.Test` (Android): `press_down_xy/4`, + `press_move_xy/3`, `press_up_xy/3`, `hold_xy/4`.** The app's own + `MobBridge` dispatches in-process `MotionEvent`s at the decor view, like the + other synthetic gestures, but the finger stays down between calls. The + bridge returns an int code, not a boolean, so `:already_pressed`, + `:not_pressed` and `:window_gone` are distinguishable from + `:dispatch_failed`. One held press at a time; the other synthetic + gestures refuse while it is down, since their own DOWN would restart the + gesture stream under it. `:max_hold_ms` (30 s default) cancels a press a + crashed test never lifted. + +5. **iOS gets by-tag driving only.** No in-process touch injection reaches + SwiftUI (decisions/2026-08-09-tap-xy-reports-observed-effect.md), so the + `_xy` NIFs return `{:error, :not_supported}`; `press_in/2`, `press_out/2` + and `hold/3` send the messages on every platform, and a real touch comes + from the simulator (mobile-mcp / agent-device). + +## Consequences + +- The Android half lives in the generated `MobBridge.kt` / `beam_jni.c`, so it + needs `mob_new` 0.6.5 or newer; an older app ignores the props and its + `capabilities/1` reports the press NIFs false. +- `decisions/2026-09-30-native-input-is-an-action.md` lists the discrete + events; `:press_in` / `:press_out` join them. +- No `mob_dev` MCP surface exists to extend; agents reach these through + `Mob.Test` over dist. +- Speech-to-text, the other half of hold-to-talk, is the `mob_speech` plugin, + not core: privacy-gated capabilities live in plugins + (plugin_extraction_plan.md, Waves 2 and 6). diff --git a/guides/components.md b/guides/components.md index ae5eaa3..59b9439 100644 --- a/guides/components.md +++ b/guides/components.md @@ -1030,6 +1030,51 @@ end | `on_submit: {pid, tag}` | `{:submit, tag}` | | `on_focus: {pid, tag}` | `{:focus, tag}` | | `on_blur: {pid, tag}` | `{:blur, tag}` | +| `on_long_press: {pid, tag}` | `{:long_press, tag}` | +| `on_press_in: {pid, tag}` | `{:press_in, tag}` | +| `on_press_out: {pid, tag}` | `{:press_out, tag}` | + +### Press and release: `on_press_in` / `on_press_out` + +`on_press_in` fires when a finger goes down on the node; `on_press_out` when +that finger lifts **or** its touch is cancelled (the system took the gesture, +the node left the screen). Sliding off the node, or an ancestor scrolling +under the finger, does not end the press: it lasts until the finger lifts. +On a node that declares both, every `{:press_in, tag}` is followed by exactly +one `{:press_out, tag}`, even when the screen re-renders in between, which a +press usually causes. They work on every node type, `:button` included, and +they observe without consuming: `on_tap`, `on_long_press` and an ancestor's +scrolling keep working on the same node. A quick tap delivers +`{:press_in, tag}`, `{:press_out, tag}`, then `{:tap, tag}`. + +That is what hold-to-talk, push-to-record, "peek" previews and press-and-hold +buttons need: + +```elixir +def render(assigns) do + ~MOB""" + + + + """ +end + +def handle_info({:press_in, :mic}, socket), + do: {:noreply, Mob.Socket.assign(socket, listening: true, pressed_at: System.monotonic_time(:millisecond))} + +def handle_info({:press_out, :mic}, socket) do + held = System.monotonic_time(:millisecond) - socket.assigns.pressed_at + # A press too short to say anything: cancel instead of transcribing. + {:noreply, Mob.Socket.assign(socket, listening: false, too_short: held < 300)} +end +``` + +Measure the hold on the BEAM as above: both events cross the same channel, +so the latency cancels out. Test it without a finger: `Mob.Test.hold/3` +sends the pair by tag on any platform, and on Android `Mob.Test.hold_xy/4` +(or `press_down_xy/4` … `press_up_xy/3`) holds a real touch through the +native detector. Android needs a bridge generated by `mob_new` 0.6.5 or newer. ### Handle limits diff --git a/guides/event_audit.md b/guides/event_audit.md index 729b88b..261a161 100644 --- a/guides/event_audit.md +++ b/guides/event_audit.md @@ -27,6 +27,8 @@ registrations. Each is opt-in per widget; absence means no event delivery. | `on_select` | `{pid, tag}` | `{:select, tag}` | Reserved for a future native selection primitive; no core picker/menu currently emits it. `Mob.List` routes indexed selection through row taps instead. | | `on_long_press` | `{pid, tag}` | `{:long_press, tag}` | Renderer + iOS NIF + native gesture; Android wired | | `on_double_tap` | `{pid, tag}` | `{:double_tap, tag}` | Same | +| `on_press_in` | `{pid, tag}` | `{:press_in, tag}` | Finger down on the node. Observes without consuming; any node type. iOS + Android (bridge from `mob_new` 0.6.5+) (MOB-380) | +| `on_press_out` | `{pid, tag}` | `{:press_out, tag}` | That finger lifted or its touch was cancelled; on a node declaring both, always follows its `press_in`. Routing is snapshotted at touch-down so a re-render can't drop it (MOB-380) | | `on_swipe` | `{pid, tag}` | `{:swipe, tag, direction}` | Direction is `:left \| :right \| :up \| :down` | | `on_swipe_left` | `{pid, tag}` | `{:swipe_left, tag}` | Specific direction only | | `on_swipe_right` | `{pid, tag}` | `{:swipe_right, tag}` | | diff --git a/guides/events.md b/guides/events.md index 3062cb3..fa12154 100644 --- a/guides/events.md +++ b/guides/events.md @@ -70,6 +70,19 @@ def handle_info({:long_press, :show_menu}, socket), do: ... def handle_info({:swipe, :any_swipe, direction}, socket), do: ... # :left | :right | :up | :down ``` +Press and release, for anything held (hold-to-talk, push-to-record): + +```elixir +box(on_press_in: {self(), :mic}, on_press_out: {self(), :mic}) + +def handle_info({:press_in, :mic}, socket), do: ... # finger down +def handle_info({:press_out, :mic}, socket), do: ... # lifted or cancelled; always follows press_in +``` + +They observe without consuming, on any node type, and a quick tap delivers +`press_in`, `press_out`, then `tap`. See the components guide, +"Press and release". + ### Scroll — three tiers **Tier 1 — raw deltas** (rarely needed; throttle defaults to 30 Hz): diff --git a/guides/mobile_surface_matrix.md b/guides/mobile_surface_matrix.md index 9a2908b..3cf1cf0 100644 --- a/guides/mobile_surface_matrix.md +++ b/guides/mobile_surface_matrix.md @@ -75,6 +75,7 @@ and orthogonal — composition over a fat component library. | Capability | Status | iOS | Android | Notes | |--|--|--|--|--| | Tap / double-tap / long-press | ✅ | ✓ | ✓ | `on_tap`, `on_double_tap`, `on_long_press` props | +| Press in / out (hold) | ✅ | ✓ | ✓ | `on_press_in`, `on_press_out` props (MOB-380); `Mob.Test.hold_xy/4` drives a real held touch on Android | | Swipe (l/r/u/d) | ✅ | ✓ | ✓ | `on_swipe_left`, etc. | | Pan / drag gesture | 🟡 | 🟡 | 🟡 | Tap-based; full pan-responder system (like react-native-gesture-handler) is missing | | Pinch / zoom | ❌ | — | — | Plugin candidate; common for image/map views | diff --git a/guides/testing.md b/guides/testing.md index 899ba7e..ef1bf1a 100644 --- a/guides/testing.md +++ b/guides/testing.md @@ -150,6 +150,35 @@ Mob.Test.tap(node, :increment) The tag atom comes from `on_tap: {self(), :increment}` in the screen's `render/1`. Fire-and-forget — does not block. Follow with `settle/2` (below) before reading the native side. +### Press and hold + +Two levels, for `on_press_in` / `on_press_out` (hold-to-talk and the like): + +```elixir +# By tag, any platform: sends {:press_in, :mic}, waits, sends {:press_out, :mic}. +Mob.Test.hold(node, :mic, 1_500) +Mob.Test.press_in(node, :mic) # or the halves, with checks in between +Mob.Test.press_out(node, :mic) + +# A real held touch (Android): the app's window gets ACTION_DOWN, then nothing +# until you lift. Compose detectors (detectTapGestures' onPress / +# tryAwaitRelease, long-press timeouts) see a finger that is genuinely down. +:ok = Mob.Test.press_down_xy(node, 40.0, 700.0) +Mob.Test.assigns(node).listening #=> true, while the finger is still down +:ok = Mob.Test.press_move_xy(node, 40.0, 600.0) # optional: slide it +:ok = Mob.Test.press_up_xy(node, 40.0, 600.0) + +:ok = Mob.Test.hold_xy(node, 40.0, 700.0, 1_500) # down, 1.5 s, up +``` + +Find coordinates with `Mob.Test.element_frames/1` (nodes with an `:id`). A +held press auto-cancels after `max_hold_ms:` (default 30 s), so a crashed test +can't leave a finger on the glass. `adb shell input swipe x y x y 3000` is +not a substitute: on some devices it never reaches Compose as a held press. +iOS has no in-process touch injection that reaches SwiftUI, so the `_xy` +functions return `{:error, :not_supported}` there; use the by-tag functions, or +a real touch through the simulator (mobile-mcp / agent-device). + ### Navigation Navigation functions are **synchronous** — they block until the navigation and re-render are complete, so it is safe to read state immediately after: diff --git a/ios/MobInputEvents.swift b/ios/MobInputEvents.swift index fafaf36..187e11f 100644 --- a/ios/MobInputEvents.swift +++ b/ios/MobInputEvents.swift @@ -125,6 +125,155 @@ struct MobContinuousInputModifier: ViewModifier { } } +// Press observation (MOB-380): on_press_in at touch-down, on_press_out when +// that finger lifts or the touch is cancelled. Observes without consuming: +// on_tap, on_long_press, a Button's own action and an enclosing ScrollView's +// pan keep working on the same node. Only attached to nodes with a press prop. +// +// The pairing itself lives native-side (MobNode.onPress / mob_begin_press): +// `begin` sends press_in and hands back the one-shot release that sends the +// matching press_out. This side only has to call that release exactly once +// per touch, whichever way the touch ends, and the view leaving the tree +// mid-press (onDisappear) counts as an end. +// +// iOS 18+: a UIKit recognizer that never recognizes (MobPressRecognizer). +// A SwiftUI `DragGesture(minimumDistance: 0)`, even as a simultaneousGesture, +// stops an enclosing ScrollView from scrolling when the drag starts on the +// node (seen on the iOS 26 simulator), and inside a ScrollView its callbacks +// for a quick tap arrive a frame after the tap's own action. The recognizer +// sees the real touch-down, can't win against anything, and is ended by +// UIKit's own reset on lift, cancel or failure. +// iOS 17 has no UIGestureRecognizerRepresentable, so it keeps the DragGesture +// (a GestureState reset covers cancellation). Unverified there: no iOS 17 +// runtime was available, so whether it blocks scrolling on 17 is unknown. +struct MobPressModifier: ViewModifier { + let begin: () -> (() -> Void)? + + @GestureState private var pressing = false + // Plain class in @State: survives node rebuilds without publishing a view + // invalidation per touch (same reasoning as MobContinuousInputModifier). + @State private var press = MobPressSession() + + func body(content: Content) -> some View { + if #available(iOS 18.0, *) { + content + .contentShape(Rectangle()) + .gesture(MobPressObserverGesture(session: press, begin: begin)) + .onDisappear { press.end() } + } else { + content + .contentShape(Rectangle()) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .updating($pressing) { _, state, _ in state = true } + .onChanged { _ in press.start(begin) } + .onEnded { _ in press.end() } + ) + .onChange(of: pressing) { _, isPressing in + if !isPressing { press.end() } + } + .onDisappear { press.end() } + } + } +} + +/// One touch's press, at most one at a time. Main thread only. +final class MobPressSession { + private var active = false + private var release: (() -> Void)? + + func start(_ begin: () -> (() -> Void)?) { + guard !active else { return } + active = true + release = begin() + } + + func end() { + guard active else { return } + active = false + let pending = release + release = nil + pending?() + } +} + +@available(iOS 18.0, *) +struct MobPressObserverGesture: UIGestureRecognizerRepresentable { + let session: MobPressSession + let begin: () -> (() -> Void)? + + func makeUIGestureRecognizer(context: Context) -> MobPressRecognizer { + let recognizer = MobPressRecognizer() + recognizer.delegate = context.coordinator + return recognizer + } + + func updateUIGestureRecognizer(_ recognizer: MobPressRecognizer, context: Context) { + // The latest render's node: its handles are the current generation. + recognizer.session = session + recognizer.begin = begin + } + + func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { + Coordinator() + } + + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer + ) -> Bool { + true + } + } +} + +/// Observes the first finger on its view and never recognizes, so it never +/// cancels, delays or excludes another recognizer or the view's own touches. +final class MobPressRecognizer: UIGestureRecognizer { + var session: MobPressSession? + var begin: (() -> (() -> Void)?)? + private weak var finger: UITouch? + + init() { + super.init(target: nil, action: nil) + cancelsTouchesInView = false + delaysTouchesBegan = false + delaysTouchesEnded = false + } + + override func touchesBegan(_ touches: Set, with event: UIEvent) { + guard finger == nil, let touch = touches.first, let begin else { return } + finger = touch + session?.start(begin) + } + + // Ending here, not only in reset(), puts press_out ahead of a tap that the + // same lift completes: reset() runs after the whole touch event has been + // delivered, by which time the tap's action has already been sent. + override func touchesEnded(_ touches: Set, with event: UIEvent) { + finish(touches) + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent) { + finish(touches) + } + + private func finish(_ touches: Set) { + guard let finger, touches.contains(finger) else { return } + session?.end() + state = .failed + } + + // UIKit calls this after .failed, and also when it abandons the recognizer + // for any other reason, so a press that didn't end above still ends here. + override func reset() { + super.reset() + finger = nil + session?.end() + } +} + extension MobNode { /// `resolvedFont` for the UIKit field (`caret: "end"`, `on_compose`, /// single-line `max_length`), from the same props. A custom font is diff --git a/ios/MobNode.h b/ios/MobNode.h index 40d3972..a06af4e 100644 --- a/ios/MobNode.h +++ b/ios/MobNode.h @@ -87,6 +87,15 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic, copy, nullable) void (^onSwipeUp)(void); @property(nonatomic, copy, nullable) void (^onSwipeDown)(void); +// Press observation (MOB-380): on_press_in / on_press_out. Set by mob_nif.m when +// either prop is registered; SwiftUI calls it at touch-down. It sends press_in +// and returns the one-shot block that sends the paired press_out, to be called +// once at lift, cancel or teardown. Returns nil when there is nothing to send at +// release (press_in could not be delivered, or no on_press_out), so a press_out +// never goes out without its press_in. The pairing lives native-side; see +// mob_begin_press in mob_nif.m for why it can't be two independent callbacks. +@property(nonatomic, copy, nullable) void (^_Nullable (^onPress)(void))(void); + // ── Batch 5 Tier 1: high-frequency scroll/drag/pinch/rotate/pointer ── // These callbacks are wired by mob_nif.m. Throttling and delta-thresholding // happen native-side BEFORE invocation — by the time these fire, the BEAM diff --git a/ios/MobRootView.swift b/ios/MobRootView.swift index d5d3184..61e350d 100644 --- a/ios/MobRootView.swift +++ b/ios/MobRootView.swift @@ -644,10 +644,15 @@ struct MobNodeView: View { // carrying an :id, so the agent can read positions via the // element_frames NIF without a screenshot. .modifier(MobFrameTracker(node: node)) - // Pinch, rotation, and pointer hover belong to the rendered node as a - // whole. Keeping this outside the type switch covers every primitive, - // including GPU/native views that do not use mobGestures(). + // Pinch, rotation, pointer hover and press in/out (MOB-380) belong to + // the rendered node as a whole. Keeping them outside the type switch + // covers every primitive — buttons, fields, sliders and toggles, which + // never get mobGestures(), and GPU/native views — with one attachment + // point instead of one per case. .modifier(MobContinuousInputModifier(node: node)) + .ifLet(node.onPress) { view, begin in + view.modifier(MobPressModifier(begin: begin)) + } // In a Row, flexible Spacer views yield only after direct text labels // reach their ideal width. Without this priority, SwiftUI compresses // labels before consuming the Spacer's slack, producing avoidable wraps. diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 8d78bc7..78f0f46 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -433,12 +433,16 @@ static void mob_note_ui_event(void) { } // Called from node onTap blocks — routes tap to BEAM via enif_send. -static void mob_send_tap(int handle) { +// identity_tolerant picks the lookup: NO (mob_send_tap) is generation-strict, +// YES (mob_send_press_tap) accepts the same slot, PID and tag across renders. +static void mob_send_tap_with(int handle, BOOL identity_tolerant) { ErlNifEnv *msg_env = enif_alloc_env(); if (!msg_env) return; TapSnap snap; - if (!mob_snap_tap(handle, msg_env, &snap)) { + int ok = identity_tolerant ? mob_snap_change_tap(handle, msg_env, &snap) + : mob_snap_tap(handle, msg_env, &snap); + if (!ok) { enif_free_env(msg_env); return; } @@ -449,6 +453,24 @@ static void mob_send_tap(int handle) { enif_free_env(msg_env); } +// Generation-strict: a handle from an earlier render is dropped, so a +// positional tag (Mob.List's {:select, id, index}) never reaches a row that +// moved under it. +static void mob_send_tap(int handle) { + mob_send_tap_with(handle, NO); +} + +// The tap of a node that also declares on_press_in / on_press_out (MOB-380). +// Identity-tolerant (mob_snap_change_tap: same slot, PID and tag across +// renders): press_in usually re-renders the screen, and set_root commits the +// new table on the BEAM thread before the main thread applies the new tree, so +// the lift that completes the tap fires the old node's handle; a strict check +// dropped those taps (measured on Android, 1-6 of 20). The identity check still +// rejects a handle whose slot now means another handler. Plain taps stay strict. +static void mob_send_press_tap(int handle) { + mob_send_tap_with(handle, YES); +} + // ── Focus / blur / submit senders ──────────────────────────────────────────── // Called from MobTextField SwiftUI view when focus state changes or return key tapped. @@ -577,6 +599,103 @@ static void mob_send_swipe_with_direction(int handle, const char *direction) { enif_free_env(msg_env); } +// ── Press observation (MOB-380) ───────────────────────────────────────────── +// press_in / press_out bracket one touch, and a hold-to-talk screen stops +// recording on press_out: a lost press_out leaves the microphone on. So unlike +// the gesture senders above, the two are resolved together, at touch-down, and +// are deliberately not generation-strict: +// +// * Both handles resolve with mob_snap_change_tap (same slot, PID and tag +// across renders), the allowance change events get. press_in usually makes +// the screen re-render, and set_root commits the new table on the BEAM +// thread before the main thread has the new tree; a strict check drops an +// event fired in that window, which for press_in is a press that never +// happened and for press_out is a press that never ends. +// * press_out's PID and tag are snapshotted BEFORE press_in is sent, and if +// either fails to resolve nothing is sent. The release block then sends from +// the snapshot, so no later render, slot reshuffle or the node leaving the +// tree can drop it: press_out goes to exactly the registration that was live +// when press_in went out. That is not the stale-tree hazard the strict rule +// guards against, which is an old handle resolving to a slot that now means +// something else. + +@interface MobPendingPressOut : NSObject { + @public + ErlNifEnv *env; // owns `tag`; NULL once sent + ErlNifPid pid; + ERL_NIF_TERM tag; +} +@end + +@implementation MobPendingPressOut +- (void)dealloc { + // The release block was dropped without being called: free, don't send. + // The Swift side calls it on lift, cancel and disappear, so this is only + // reached if SwiftUI discards the view's state without any of those. + if (env) + enif_free_env(env); +} +@end + +// Main thread only (the release block's caller), which is what makes the +// one-shot check-and-clear of `env` safe without a lock. +static void mob_send_pending_press_out(MobPendingPressOut *pending) { + ErlNifEnv *env = pending->env; + if (!env) + return; + pending->env = NULL; + mob_note_ui_event(); + ERL_NIF_TERM msg = enif_make_tuple2(env, enif_make_atom(env, "press_out"), pending->tag); + enif_send(NULL, &pending->pid, env, msg); + enif_free_env(env); +} + +// Returns 1 if {press_in, tag} was sent. +static int mob_send_press_in(int handle) { + ErlNifEnv *msg_env = enif_alloc_env(); + if (!msg_env) + return 0; + TapSnap snap; + if (!mob_snap_change_tap(handle, msg_env, &snap)) { + enif_free_env(msg_env); + return 0; + } + mob_note_ui_event(); + ERL_NIF_TERM msg = enif_make_tuple2(msg_env, enif_make_atom(msg_env, "press_in"), snap.tag); + enif_send(NULL, &snap.pid, msg_env, msg); + enif_free_env(msg_env); + return 1; +} + +// in_handle / out_handle are 0 when the node has no such prop (a real handle +// is never 0: its generation bits are non-zero). Returns the release block, or +// nil when there is nothing to send at release. +static void (^mob_begin_press(int in_handle, int out_handle))(void) { + MobPendingPressOut *pending = nil; + if (out_handle != 0) { + ErlNifEnv *env = enif_alloc_env(); + if (!env) + return nil; + TapSnap snap; + if (!mob_snap_change_tap(out_handle, env, &snap)) { + enif_free_env(env); + return nil; + } + pending = [MobPendingPressOut new]; + pending->env = env; + pending->pid = snap.pid; + pending->tag = snap.tag; + } + // `pending` is released (and its env freed) on this early return. + if (in_handle != 0 && !mob_send_press_in(in_handle)) + return nil; + if (!pending) + return nil; + return ^{ + mob_send_pending_press_out(pending); + }; +} + // ── Batch 5 Tier 1: high-frequency scroll/drag/pinch/rotate senders ───────── // Each respects the per-handle throttle config set via mob_set_throttle_config. // The envelope follows the canonical Mob.Event shape but is constructed at @@ -974,6 +1093,8 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { MOB_PROP_on_long_press, MOB_PROP_on_pinch, MOB_PROP_on_pointer_move, + MOB_PROP_on_press_in, + MOB_PROP_on_press_out, MOB_PROP_on_rotate, MOB_PROP_on_scroll, MOB_PROP_on_scroll_began, @@ -1109,6 +1230,8 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { [MOB_PROP_on_long_press] = @"on_long_press", [MOB_PROP_on_pinch] = @"on_pinch", [MOB_PROP_on_pointer_move] = @"on_pointer_move", + [MOB_PROP_on_press_in] = @"on_press_in", + [MOB_PROP_on_press_out] = @"on_press_out", [MOB_PROP_on_rotate] = @"on_rotate", [MOB_PROP_on_scroll] = @"on_scroll", [MOB_PROP_on_scroll_began] = @"on_scroll_began", @@ -1425,9 +1548,19 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { id onTap = pv[MOB_PROP_on_tap]; if (onTap && [onTap isKindOfClass:[NSNumber class]]) { int handle = [onTap intValue]; - node.onTap = ^{ - mob_send_tap(handle); - }; + // A node that observes press in/out re-renders mid-touch, so its + // tap resolves identity-tolerantly; every other tap stays strict. + BOOL pressNode = [pv[MOB_PROP_on_press_in] isKindOfClass:[NSNumber class]] || + [pv[MOB_PROP_on_press_out] isKindOfClass:[NSNumber class]]; + if (pressNode) { + node.onTap = ^{ + mob_send_press_tap(handle); + }; + } else { + node.onTap = ^{ + mob_send_tap(handle); + }; + } } id placeholder = pv[MOB_PROP_placeholder]; @@ -1511,6 +1644,18 @@ typedef NS_ENUM(NSUInteger, MobPropKey) { }; } + id onPressIn = pv[MOB_PROP_on_press_in]; + id onPressOut = pv[MOB_PROP_on_press_out]; + BOOL hasPressIn = onPressIn && [onPressIn isKindOfClass:[NSNumber class]]; + BOOL hasPressOut = onPressOut && [onPressOut isKindOfClass:[NSNumber class]]; + if (hasPressIn || hasPressOut) { + int inHandle = hasPressIn ? [onPressIn intValue] : 0; + int outHandle = hasPressOut ? [onPressOut intValue] : 0; + node.onPress = ^void(^_Nullable(void))(void) { + return mob_begin_press(inHandle, outHandle); + }; + } + id onDoubleTap = pv[MOB_PROP_on_double_tap]; if (onDoubleTap && [onDoubleTap isKindOfClass:[NSNumber class]]) { int handle = [onDoubleTap intValue]; @@ -6883,6 +7028,54 @@ static ERL_NIF_TERM nif_long_press_xy(ErlNifEnv *env, int argc, const ERL_NIF_TE #endif } +// ─── press_down_xy/3, press_move_xy/2, press_up_xy/2 — split touch (MOB-380) ── +// +// A finger that goes down, optionally moves, and lifts in separate calls, so a +// test can hold a node (on_press_in / on_press_out) for as long as it likes and +// assert in between. Android injects real in-process MotionEvents. iOS does +// not implement split touch injection (yet): the simulator branches above fire +// gestures by forcing UIKit recognizer states or accessibility actions, not by +// delivering a held touch stream. So these return {error, not_supported} +// rather than pretending to have held anything. +// Mob.Test.press_in/2, press_out/2 and hold/3 cover iOS at the tag level, and +// capabilities/0 reports these three as false so an agent can tell up front. +// +// Arguments are still validated, so a malformed call is badarg on both +// platforms rather than only on the one that implements it. +// +// Returns: {error, not_supported} | badarg + +static ERL_NIF_TERM mob_press_not_supported(ErlNifEnv *env) { + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_supported")); +} + +static ERL_NIF_TERM nif_press_down_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + double x, y; + int max_hold_ms; + if (!enif_get_double(env, argv[0], &x) || !enif_get_double(env, argv[1], &y) || + !enif_get_int(env, argv[2], &max_hold_ms)) + return enif_make_badarg(env); + return mob_press_not_supported(env); +} + +static ERL_NIF_TERM nif_press_move_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + double x, y; + if (!enif_get_double(env, argv[0], &x) || !enif_get_double(env, argv[1], &y)) + return enif_make_badarg(env); + return mob_press_not_supported(env); +} + +static ERL_NIF_TERM nif_press_up_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + double x, y; + if (!enif_get_double(env, argv[0], &x) || !enif_get_double(env, argv[1], &y)) + return enif_make_badarg(env); + return mob_press_not_supported(env); +} + // ─── type_text/1 — type into whatever UITextField/UITextView has focus ──────── // // Finds the current first responder in the view hierarchy and calls insertText: @@ -7592,6 +7785,11 @@ static ERL_NIF_TERM nif_capabilities(ErlNifEnv *env, int argc, const ERL_NIF_TER {"tap_xy", harness}, {"tap_by_label", harness}, {"long_press_xy", harness}, + // Split press injection is Android-only (MOB-380): on iOS these exist + // in debug builds but always return {error, not_supported}. + {"press_down_xy", 0}, + {"press_move_xy", 0}, + {"press_up_xy", 0}, {"swipe_xy", harness}, {"type_text", harness}, {"delete_backward", harness}, @@ -8811,6 +9009,10 @@ static ERL_NIF_TERM nif_post_mortem_android_drain(ErlNifEnv *env, int argc, {"key_press", 1, nif_key_press, ERL_NIF_DIRTY_JOB_IO_BOUND}, {"clear_text", 0, nif_clear_text, ERL_NIF_DIRTY_JOB_IO_BOUND}, {"long_press_xy", 3, nif_long_press_xy, ERL_NIF_DIRTY_JOB_IO_BOUND}, + // Validate and return {error, not_supported}; nothing waits, so not dirty. + {"press_down_xy", 3, nif_press_down_xy, 0}, + {"press_move_xy", 2, nif_press_move_xy, 0}, + {"press_up_xy", 2, nif_press_up_xy, 0}, {"swipe_xy", 4, nif_swipe_xy, ERL_NIF_DIRTY_JOB_IO_BOUND}, #endif {"capabilities", 0, nif_capabilities, 0}, diff --git a/lib/mob/event/native_input.ex b/lib/mob/event/native_input.ex index 73ead2a..b19e7b8 100644 --- a/lib/mob/event/native_input.ex +++ b/lib/mob/event/native_input.ex @@ -15,7 +15,7 @@ defmodule Mob.Event.NativeInput do | Kind | Messages | Receipt | Trace | |---|---|---|---| - | `:discrete` | `:tap` (list-row select included), `:focus`, `:blur`, `:submit`, `:select`, `:dismiss`, `:long_press`, `:double_tap`, `:swipe_left`/`_right`/`_up`/`_down`, `{:swipe, tag, direction}`, `{:change, tag, value}` except a float | yes | yes | + | `:discrete` | `:tap` (list-row select included), `:focus`, `:blur`, `:submit`, `:select`, `:dismiss`, `:long_press`, `:double_tap`, `:press_in`, `:press_out`, `:swipe_left`/`_right`/`_up`/`_down`, `{:swipe, tag, direction}`, `{:change, tag, value}` except a float | yes | yes | | `:stream` | `:scroll`, `:drag`, `:pinch`, `:rotate`, `:pointer_move`, `:compose`, a float `:change`, and the scroll lifecycle (`:scroll_began`, `:scroll_ended`, `:scroll_settled`, `:top_reached`, `:scrolled_past`) | no | yes | A stream fires at up to display rate, or several times per gesture. The @@ -56,6 +56,8 @@ defmodule Mob.Event.NativeInput do :dismiss, :long_press, :double_tap, + :press_in, + :press_out, :swipe_left, :swipe_right, :swipe_up, diff --git a/lib/mob/render_stats.ex b/lib/mob/render_stats.ex index 1c0e9b1..c6f65e3 100644 --- a/lib/mob/render_stats.ex +++ b/lib/mob/render_stats.ex @@ -489,6 +489,7 @@ defmodule Mob.RenderStats do # of this reported 1 tap on a tree that made 12 calls. @handle_props MapSet.new(~w(on_tap on_change on_focus on_blur on_submit on_dismiss on_select on_scroll on_drag on_pinch on_rotate on_long_press on_double_tap + on_press_in on_press_out on_swipe on_swipe_left on_swipe_right on_swipe_up on_swipe_down on_compose on_end_reached on_tab_select on_pointer_move on_scroll_began on_scroll_ended on_scroll_settled on_top_reached diff --git a/lib/mob/renderer.ex b/lib/mob/renderer.ex index 5d0aebd..b62fb07 100644 --- a/lib/mob/renderer.ex +++ b/lib/mob/renderer.ex @@ -465,6 +465,17 @@ defmodule Mob.Renderer do {:on_double_tap, {pid, tag}} when is_pid(pid) -> [{"on_double_tap", register_handler(nif, {pid, tag})}] + # Press in / out (MOB-380): finger down on the node / that finger lifted + # or its gesture was cancelled. Observes without consuming, so on_tap, + # long press and scrolling on the same node keep working. Native + # snapshots press_out's routing at touch-down, so the pair survives the + # re-render a press usually causes. + {:on_press_in, {pid, tag}} when is_pid(pid) -> + [{"on_press_in", register_handler(nif, {pid, tag})}] + + {:on_press_out, {pid, tag}} when is_pid(pid) -> + [{"on_press_out", register_handler(nif, {pid, tag})}] + {:on_swipe, {pid, tag}} when is_pid(pid) -> [{"on_swipe", register_handler(nif, {pid, tag})}] diff --git a/lib/mob/test.ex b/lib/mob/test.ex index 40c7fab..a89fc4b 100644 --- a/lib/mob/test.ex +++ b/lib/mob/test.ex @@ -120,6 +120,7 @@ defmodule Mob.Test do |------------------------------|---------------|---------------|-----------------| | `screen/1`, `assigns/1` | ✅ | ✅ | ✅ | | `tap/2` (by tag) | ✅ | ✅ | ✅ | + | `press_in/2`, `press_out/2`, `hold/3` (by tag) | ✅ | ✅ | ✅ | | `back/1`, `pop/1`, `navigate`| ✅ | ✅ | ✅ | | `send_message/2` | ✅ | ✅ | ✅ | | `screen_info/1` | ✅ | ✅ | ✅ | @@ -134,6 +135,7 @@ defmodule Mob.Test do | `adjust_slider/4` | ⚠️ AX active§ | ⚠️ AX active§ | ❌ ui_tree_unavailable | | `tap_xy/3` | ⚠️ AX-activatable only¶ | ❌ no_effect¶ | ✅ ⊕ | | `long_press_xy/4` | ⚠️ acceptance only✱| ⚠️ acceptance only✱| ✅ ⊕ | + | `press_down_xy/4`, `press_move_xy/3`, `press_up_xy/3`, `hold_xy/4` | ❌ not_supported | ❌ not_supported | ✅ ⊕ | | `swipe/5` | ⚠️ scroll only| ⚠️ acceptance only✱| ✅ ⊕ | | `type_text/2` | ⚠️ acceptance only✱| ⚠️ acceptance only✱| ✅ ASCII only⊕ | | `delete_backward/1` | ⚠️ acceptance only✱| ⚠️ acceptance only✱| ✅ ⊕ | @@ -187,6 +189,11 @@ defmodule Mob.Test do an existing app needs regenerating. `capabilities/1` answers this for the build in front of you; the table cannot. + The held press (`press_down_xy/4` and friends) and the `on_press_in` / + `on_press_out` props need a bridge from `mob_new` 0.6.5 or newer + (MOB-380): an older app answers `{:error, :not_loaded}` here and never + fires the props. + Four more consequences worth knowing before you rely on it: * **Gestures cost real wall-clock.** A long press or swipe has to hold @@ -543,7 +550,10 @@ defmodule Mob.Test do :scroll_to, :sample_region, :screenshot, - :native_stats + :native_stats, + :press_down_xy, + :press_move_xy, + :press_up_xy ] @doc false @@ -1204,7 +1214,10 @@ defmodule Mob.Test do **not working on device** and use `tap/2`. See `decisions/2026-08-09-ios-device-tap-injection-has-no-effect.md`. - **Android** — not routed through this function; `adb shell input tap` works - and is what the tooling uses. + and is what the tooling uses. While a `press_down_xy/4` is held, the + in-process synthetic gestures (`tap_xy`, `long_press_xy`, `swipe_xy`) + refuse and answer `{:error, :dispatch_failed}`: lift it with + `press_up_xy/3` first. ## `:no_effect` in sidecar mode @@ -1293,6 +1306,138 @@ defmodule Mob.Test do :rpc.call(node, :mob_nif, :swipe_xy, [x1 * 1.0, y1 * 1.0, x2 * 1.0, y2 * 1.0]) end + # ── Press and hold (MOB-380) ────────────────────────────────────────────────── + + @doc """ + Send `{:press_in, tag}` to the current screen, as a finger landing on a node + with `on_press_in: {pid, tag}` would. Works on every platform; no touch is + involved, so it tests the screen's handling, not the native detector. Pair + it with `press_out/2`, or use `hold/3`. + + Mob.Test.press_in(node, :mic) + """ + @spec press_in(node(), term()) :: :ok + def press_in(node, tag) do + :rpc.call(node, Process, :send, [:mob_screen, {:press_in, tag}, []]) + :ok + end + + @doc "Send `{:press_out, tag}` to the current screen. See `press_in/2`." + @spec press_out(node(), term()) :: :ok + def press_out(node, tag) do + :rpc.call(node, Process, :send, [:mob_screen, {:press_out, tag}, []]) + :ok + end + + @doc """ + `press_in/2`, wait `duration_ms`, `press_out/2`: a hold, by tag, on every + platform. Blocks the caller for the duration. + + Mob.Test.hold(node, :mic, 1_500) + """ + @spec hold(node(), term(), non_neg_integer()) :: :ok + def hold(node, tag, duration_ms) when is_integer(duration_ms) and duration_ms >= 0 do + press_in(node, tag) + Process.sleep(duration_ms) + press_out(node, tag) + end + + @default_max_hold_ms 30_000 + + @doc """ + Put a finger down at (x, y) dp and leave it there until `press_up_xy/3`. + + A genuine held touch, not a message: on Android the app's own window gets an + in-process `ACTION_DOWN`, so `on_press_in` fires through the native detector, + and a Compose `detectTapGestures` `onPress` is still waiting in + `tryAwaitRelease()` while you look at the app (`assigns/1`, `screenshot/2`, + logs). Between down and up you can read state, move the finger with + `press_move_xy/3`, or send messages; other synthetic gestures (`tap_xy/3`, + `swipe/5`, `long_press_xy/4`) refuse while the finger is down, since their + own touch would restart the gesture under it. + + Options: + + * `:max_hold_ms` (default #{@default_max_hold_ms}): the finger is + cancelled, not released, after this long, so a test that crashes + between down and up does not leave the app believing a finger is on the + glass for the rest of the session. + + Returns `:ok`, or `{:error, reason}`: `:already_pressed` (one held press at + a time), `:dispatch_failed` (nothing in the window took the touch), + `:no_window`, `:timeout` (the UI thread didn't answer within 2 s, e.g. + another synthetic gesture still running), `:not_loaded` (an app whose generated `MobBridge.kt` predates + the held-press methods — see the `⊕` note above; `capabilities/1` tells you), + `:not_supported` (iOS: no in-process touch injection reaches SwiftUI; drive + the screen with `press_in/2` / `hold/3`, or a real touch through the + simulator). + + :ok = Mob.Test.press_down_xy(node, 40.0, 700.0) + Mob.Test.assigns(node).listening #=> true + :ok = Mob.Test.press_up_xy(node, 40.0, 700.0) + """ + @spec press_down_xy(node(), number(), number(), keyword()) :: :ok | {:error, term()} + def press_down_xy(node, x, y, opts \\ []) do + max_hold_ms = Keyword.get(opts, :max_hold_ms, @default_max_hold_ms) + + node + |> :rpc.call(:mob_nif, :press_down_xy, [x * 1.0, y * 1.0, max_hold_ms]) + |> harness_result() + end + + @doc """ + Move the finger held by `press_down_xy/4` to (x, y) dp, over a few real + frames so touch slop can be crossed. `{:error, :not_pressed}` when no finger + is down. + """ + @spec press_move_xy(node(), number(), number()) :: :ok | {:error, term()} + def press_move_xy(node, x, y) do + node + |> :rpc.call(:mob_nif, :press_move_xy, [x * 1.0, y * 1.0]) + |> harness_result() + end + + @doc """ + Lift the finger held by `press_down_xy/4` at (x, y) dp (moving it there + first if it is elsewhere). `{:error, :not_pressed}` when no finger is down + (including after `:max_hold_ms` cancelled it); `{:error, :window_gone}` when + the window the press started in has since been detached. + """ + @spec press_up_xy(node(), number(), number()) :: :ok | {:error, term()} + def press_up_xy(node, x, y) do + node + |> :rpc.call(:mob_nif, :press_up_xy, [x * 1.0, y * 1.0]) + |> harness_result() + end + + @doc """ + Hold a real finger at (x, y) dp for `duration_ms`, then lift it: + `press_down_xy/4`, sleep, `press_up_xy/3`. Unlike `long_press_xy/4` each + stage reports its own result, and the press stays a press — use this for + hold-to-talk and anything else keyed on press in / out. + + :ok = Mob.Test.hold_xy(node, 40.0, 700.0, 1_500) + """ + @spec hold_xy(node(), number(), number(), non_neg_integer()) :: :ok | {:error, term()} + def hold_xy(node, x, y, duration_ms) when is_integer(duration_ms) and duration_ms >= 0 do + with :ok <- press_down_xy(node, x, y, max_hold_ms: duration_ms + 10_000) do + Process.sleep(duration_ms) + press_up_xy(node, x, y) + end + end + + @doc false + # An app running a mob without these NIFs answers `undef`; say what that + # means rather than handing the caller an RPC exit to decode. + @spec harness_result(term()) :: :ok | {:error, term()} + def harness_result(:ok), do: :ok + def harness_result({:error, _reason} = error), do: error + def harness_result({:badrpc, {:EXIT, {:undef, _}}}), do: {:error, :not_loaded} + def harness_result({:badrpc, {:EXIT, {:not_loaded, _}}}), do: {:error, :not_loaded} + def harness_result({:badrpc, reason}), do: {:error, {:badrpc, reason}} + # The Android NIFs answer a bare `error` when the thread can't attach to the JVM. + def harness_result(:error), do: {:error, :jni_attach_failed} + @doc """ Find elements in the native accessibility tree whose label or value contains `text`. diff --git a/src/mob_nif.erl b/src/mob_nif.erl index 03e1fcf..1f0945a 100644 --- a/src/mob_nif.erl +++ b/src/mob_nif.erl @@ -106,6 +106,9 @@ clear_text/0, long_press_xy/3, swipe_xy/4, + press_down_xy/3, + press_move_xy/2, + press_up_xy/2, %% Test harness — in-process visual capture and scroll control %% (remote-driving: agent gets pixels + deterministic scroll over dist, %% no adb/xcrun). See Mob.Test.screenshot/2, scroll_info/2, scroll_to/3. @@ -204,6 +207,9 @@ clear_text/0, long_press_xy/3, swipe_xy/4, + press_down_xy/3, + press_move_xy/2, + press_up_xy/2, screenshot/3, sample_region/4, scroll_info/1, @@ -365,6 +371,12 @@ key_press(_Key) -> erlang:nif_error(not_loaded). clear_text() -> erlang:nif_error(not_loaded). long_press_xy(_X, _Y, _Ms) -> erlang:nif_error(not_loaded). swipe_xy(_X1, _Y1, _X2, _Y2) -> erlang:nif_error(not_loaded). +%% Held press (MOB-380): a finger that stays down across calls. Android +%% dispatches in-process MotionEvents; iOS returns {error, not_supported}. +%% press_down_xy(X, Y, MaxHoldMs) -> ok | {error, Reason} +press_down_xy(_X, _Y, _MaxHoldMs) -> erlang:nif_error(not_loaded). +press_move_xy(_X, _Y) -> erlang:nif_error(not_loaded). +press_up_xy(_X, _Y) -> erlang:nif_error(not_loaded). %% In-process visual capture + scroll control (see Mob.Test). %% screenshot(Format, Quality, Scale) -> Binary (PNG/JPEG bytes) | {error, Reason} %% Format :: png | jpeg, Quality :: 0..100 (jpeg), Scale :: float diff --git a/test/mob/agent/native_input_receipts_test.exs b/test/mob/agent/native_input_receipts_test.exs index 5f6db25..d7fa7ba 100644 --- a/test/mob/agent/native_input_receipts_test.exs +++ b/test/mob/agent/native_input_receipts_test.exs @@ -93,6 +93,14 @@ defmodule Mob.Agent.NativeInputReceiptsTest do assert Receipt.owner(receipt) == :app_code end + test "a press in and out are each receipted as discrete input on the node", %{screen: screen} do + deliver(screen, {:press_in, :mic}) + assert {:press_in, %Address{screen: Screen, id: :mic}} = recent().event + + deliver(screen, {:press_out, :mic}) + assert {:press_out, %Address{screen: Screen, id: :mic}} = recent().event + end + test "a raising tap handler is recorded on the way out", %{screen: screen} do ref = Process.monitor(screen) diff --git a/test/mob/input_nif_scheduling_test.exs b/test/mob/input_nif_scheduling_test.exs index 16ee0c2..189f970 100644 --- a/test/mob/input_nif_scheduling_test.exs +++ b/test/mob/input_nif_scheduling_test.exs @@ -29,7 +29,10 @@ defmodule Mob.InputNifSchedulingTest do # three of the Android ones waited on a latch with no timeout at all. @blocking_both ~w(screen_info scroll_to clipboard_get webview_can_go_back safe_area) - @android_only_blocking ~w() + # MOB-380's held press: each call waits on the main looper (and on any + # gesture holding the gesture mutex). iOS's are stubs that return + # :not_supported, so they stay on a normal scheduler there. + @android_only_blocking ~w(press_down_xy press_move_xy press_up_xy) # iOS blocks on these; Android answers them without touching the UI thread, # so flagging them there would buy a scheduler hop and nothing. diff --git a/test/mob/native_event_handle_test.exs b/test/mob/native_event_handle_test.exs index 8a8ce96..a3a45a9 100644 --- a/test/mob/native_event_handle_test.exs +++ b/test/mob/native_event_handle_test.exs @@ -201,6 +201,55 @@ defmodule Mob.NativeEventHandleTest do assert ios_dismiss =~ "mob_send_identity_event(handle, \"dismiss\")" end + test "plain taps stay strict; press-node taps and press pairs survive a re-render" do + # A plain tap resolves generation-strictly: a stale positional tag such as + # Mob.List's {:select, id, index} must be dropped, not routed to whatever + # row now sits in that slot. + [_, android_tap] = String.split(@android_source, "pub export fn mob_send_tap(", parts: 2) + [android_tap, _] = String.split(android_tap, "}", parts: 2) + assert android_tap =~ "sendEvent(handle, \"tap\")" + + [_, ios_tap] = String.split(@ios_source, "static void mob_send_tap(", parts: 2) + [ios_tap, _] = String.split(ios_tap, "}", parts: 2) + assert ios_tap =~ "mob_send_tap_with(handle, NO)" + + # MOB-380: on_press_in almost always re-renders before the finger lifts, so + # the tap of a node that observes press in/out resolves by identity. + [_, android_press_tap] = + String.split(@android_source, "pub export fn mob_send_press_tap(", parts: 2) + + [android_press_tap, _] = String.split(android_press_tap, "}", parts: 2) + assert android_press_tap =~ "sendIdentityEvent(handle, \"tap\")" + + [_, ios_press_tap] = String.split(@ios_source, "static void mob_send_press_tap(", parts: 2) + [ios_press_tap, _] = String.split(ios_press_tap, "}", parts: 2) + assert ios_press_tap =~ "mob_send_tap_with(handle, YES)" + + [_, ios_with] = String.split(@ios_source, "static void mob_send_tap_with(", parts: 2) + [ios_with, _] = String.split(ios_with, "static void mob_send_tap(", parts: 2) + assert ios_with =~ "identity_tolerant ? mob_snap_change_tap(handle, msg_env, &snap)" + assert ios_with =~ ": mob_snap_tap(handle, msg_env, &snap)" + + # iOS picks the tolerant sender only for a node declaring press in/out. + [_, ios_on_tap] = String.split(@ios_source, "id onTap = pv[MOB_PROP_on_tap];", parts: 2) + + [ios_on_tap, _] = + String.split(ios_on_tap, "id placeholder = pv[MOB_PROP_placeholder];", parts: 2) + + assert ios_on_tap =~ "pv[MOB_PROP_on_press_in]" + assert ios_on_tap =~ "pv[MOB_PROP_on_press_out]" + assert ios_on_tap =~ "mob_send_press_tap(handle)" + + # press_out's routing is copied at touch-down and sent from that copy. + [_, begin] = String.split(@android_source, "pub export fn mob_press_begin(", parts: 2) + [begin, _] = String.split(begin, "pub export fn mob_press_end(", parts: 2) + assert begin =~ "snapChangeTap(out_handle, keep)" + assert begin =~ "snapChangeTap(in_handle, env)" + {snap_out, _} = :binary.match(begin, "snapChangeTap(out_handle, keep)") + {send_in, _} = :binary.match(begin, "erts.enif_send(null, &pid, env, msg)") + assert snap_out < send_in + end + test "iOS copies every routed tag while holding the registry lock" do [_, snap] = String.split(@ios_source, "static int mob_snap_tap", parts: 2) [snap, _] = String.split(snap, "static int mob_snap_change_tap", parts: 2) diff --git a/test/mob/nif_scheduling_completeness_test.exs b/test/mob/nif_scheduling_completeness_test.exs index ef80362..4c23e0d 100644 --- a/test/mob/nif_scheduling_completeness_test.exs +++ b/test/mob/nif_scheduling_completeness_test.exs @@ -53,6 +53,7 @@ defmodule Mob.NifSchedulingCompletenessTest do device_network_state device_os_version device_set_dispatcher device_thermal_state exit_app files_pick haptic log motion_start motion_stop native_stats_enable open_settings open_url + press_down_xy press_move_xy press_up_xy platform post_mortem_android_drain post_mortem_ios_drain register_component register_tap request_permission share_text storage_dir storage_external_files_dir storage_save_to_media_store storage_save_to_photo_library diff --git a/test/mob/renderer_test.exs b/test/mob/renderer_test.exs index d8c72e3..3f70104 100644 --- a/test/mob/renderer_test.exs +++ b/test/mob/renderer_test.exs @@ -349,6 +349,27 @@ defmodule Mob.RendererTest do assert is_integer(decoded["props"]["on_double_tap"]) end + test "on_press_in / on_press_out register their own handles, carrying the tag" do + pid = self() + + tree = %{ + type: :box, + props: %{on_press_in: {pid, :mic}, on_press_out: {pid, :mic_up}}, + children: [] + } + + Renderer.render(tree, :android, MockNIF) + {:set_root, [json]} = Enum.find(MockNIF.calls(), fn {f, _} -> f == :set_root end) + decoded = :json.decode(json) + assert is_integer(decoded["props"]["on_press_in"]) + assert is_integer(decoded["props"]["on_press_out"]) + assert decoded["props"]["on_press_in"] != decoded["props"]["on_press_out"] + + registered = for {:register_tap, [target]} <- MockNIF.calls(), do: target + assert {pid, :mic} in registered + assert {pid, :mic_up} in registered + end + test "on_swipe and directional swipes are replaced by integer handles" do pid = self() diff --git a/test/mob/test_test.exs b/test/mob/test_test.exs index fda7483..3a2dcbe 100644 --- a/test/mob/test_test.exs +++ b/test/mob/test_test.exs @@ -524,4 +524,24 @@ defmodule Mob.TestTest do M.sample_color(:"nonexistent_mob_node@127.0.0.1", {0.0, 0.0, 10.0, 10.0}) end end + + describe "held press (press_down_xy/4 and friends)" do + test "a node whose mob has no press NIFs answers :not_loaded, not an RPC exit" do + # This node's :mob_nif never loads its library, exactly like a device + # running a mob older than MOB-380. + assert {:error, :not_loaded} = M.press_down_xy(node(), 10, 20) + assert {:error, :not_loaded} = M.press_move_xy(node(), 10, 20) + assert {:error, :not_loaded} = M.press_up_xy(node(), 10, 20) + end + + test "hold_xy stops at a failed press_down instead of sleeping and lifting" do + {elapsed_us, result} = :timer.tc(fn -> M.hold_xy(node(), 10, 20, 2_000) end) + assert result == {:error, :not_loaded} + assert elapsed_us < 1_000_000 + end + + test "an unreachable node surfaces the dist failure" do + assert {:error, {:badrpc, _}} = M.press_down_xy(:"nonexistent_mob_node@127.0.0.1", 1, 2) + end + end end