Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions android/jni/mob_beam.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
200 changes: 198 additions & 2 deletions android/jni/mob_nif.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 },
Expand Down
Loading
Loading