Skip to content
Draft
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
54 changes: 38 additions & 16 deletions src/fw/applib/ui/recognizer/touch_nav.c
Original file line number Diff line number Diff line change
Expand Up @@ -141,26 +141,37 @@ static bool prv_registry_contains_layer(TouchNavState *state, const struct Layer
return false;
}

// @return the single registered widget layer across both registries, or NULL if there is not
// exactly one.
static struct Layer *prv_registry_sole_widget(TouchNavState *state) {
struct Layer *sole = NULL;
// @return the single registered widget node across all three registries, or NULL if there is not
// exactly one. The dead-zone routing (prv_resolve_route, prv_resolve_widget_target) falls back to the window's sole widget, so both need the node itself, not just its layer.
static TouchNavWidgetNode *prv_registry_sole_widget(TouchNavState *state) {
TouchNavWidgetNode *sole = NULL;
uint32_t count = 0;
for (TouchNavWidgetNode *n = state->menu_head; n; n = n->next) {
sole = n->layer;
sole = n;
count++;
}
for (TouchNavWidgetNode *n = state->swap_head; n; n = n->next) {
sole = n->layer;
sole = n;
count++;
}
for (TouchNavWidgetNode *n = state->scroll_head; n; n = n->next) {
sole = n->layer;
sole = n;
count++;
}
return (count == 1) ? sole : NULL;
}

// The dead-zone fallback widget: the window's sole registered widget, but only when it actually lies under the dead-zone strip (its layer frame contains the Touchdown point). The dead zone exists because the status bar swallows hit-testing over content that continues beneath it -- a full-bleed menu under the strip should still receive the touch. A widget positioned away from the strip has nothing under the touch and must not capture it.
static TouchNavWidgetNode *prv_registry_sole_widget_under(TouchNavState *state,
const TouchEvent *touchdown) {
TouchNavWidgetNode *const sole = prv_registry_sole_widget(state);
if (!sole || !sole->layer) {
return NULL;
}
const GPoint point = GPoint(touchdown->x, touchdown->y);
return grect_contains_point(&sole->layer->frame, &point) ? sole : NULL;
}

// ---------------------------------------------------------------------------------------------
// Action-bar tap zoning

Expand Down Expand Up @@ -211,7 +222,10 @@ ButtonId touch_nav_action_bar_zone_button(const TouchNavActionBar *bar, GPoint p
// the old attach-order arbitration. If that winning node is un-migrated (ops == NULL) the gesture
// belongs to the widget's own recognizer set, so return NULL and let that set drive it -- the
// unified set only ever drives ops-bearing (migrated) widgets.
static TouchNavWidgetNode *prv_resolve_widget_target(TouchNavState *state) {
//
// The touchdown event supplies the status-bar dead-zone fallback: when the parent walk finds nothing (the touch hit a layer outside every registered widget, e.g. the status-bar strip) and the Touchdown landed inside the dead zone, the window's sole registered widget owns the gesture -- the same fallback prv_resolve_route commits to when it answers TouchNavRoute_Tier1. Without it here, the router fails the bridge set while the latch fails the unified set and the touch drives nothing.
static TouchNavWidgetNode *prv_resolve_widget_target(TouchNavState *state,
const TouchEvent *touchdown) {
if (!state || !state->manager) {
return NULL;
}
Expand All @@ -225,16 +239,23 @@ static TouchNavWidgetNode *prv_resolve_widget_target(TouchNavState *state) {
}
}
}
if (touchdown && touchdown->type == TouchEvent_Touchdown &&
touchdown->y < TOUCH_NAV_STATUS_BAR_DEAD_ZONE_PX) {
TouchNavWidgetNode *const sole = prv_registry_sole_widget_under(state, touchdown);
return (sole && sole->ops) ? sole : NULL;
}
return NULL;
}

// Touch filter for the unified widget set: handle a gesture only when the latched active layer
// resolves to a migrated (ops-bearing) widget. A filtered-out recognizer returns without advancing,
// so on a bridge / un-migrated-widget route it never Starts and never competes with the bridge set
// or an un-migrated widget's own set on this task's global recognizer list.
// Touch filter for the unified widget set: handle a gesture only when the latched active layer resolves to a migrated (ops-bearing) widget. A filtered-out recognizer returns without advancing, so on a bridge / un-migrated-widget route it never Starts and never competes with the bridge set or an un-migrated widget's own set on this task's global recognizer list.
//
// The filter runs on every event of the gesture, so it must stay gesture-stable: a Touchdown is resolved fresh from the event itself (the only event whose dead-zone coordinate is meaningful), while later events answer from the route the Touchdown dispatch already latched -- otherwise a finger dragging out of the dead zone would drop the unified set mid-gesture.
static bool prv_widget_touch_filter(const Recognizer *recognizer, const TouchEvent *touch_event) {
TouchNavState *state = recognizer_get_user_data(recognizer);
return prv_resolve_widget_target(state) != NULL;
if (touch_event->type == TouchEvent_Touchdown) {
return prv_resolve_widget_target(state, touch_event) != NULL;
}
return state->route == TouchNavRoute_Tier1;
}

// Unified widget dispatch. Mirrors the per-widget recognizer callbacks this replaces, but drives the
Expand Down Expand Up @@ -323,9 +344,10 @@ static TouchNavRoute prv_resolve_route(TouchNavState *state, const TouchEvent *t
}
}

// Status-bar dead zone: route to the window's sole widget, else drop.
// Status-bar dead zone: route to the window's sole widget when it lies under the strip, else drop.
if (touchdown->y < TOUCH_NAV_STATUS_BAR_DEAD_ZONE_PX) {
return prv_registry_sole_widget(state) ? TouchNavRoute_Tier1 : TouchNavRoute_Dropped;
return prv_registry_sole_widget_under(state, touchdown) ? TouchNavRoute_Tier1 :
TouchNavRoute_Dropped;
}

// Otherwise the Tier-2 bridge, unless the window opted out.
Expand Down Expand Up @@ -521,7 +543,7 @@ void touch_nav_dispatch(const TouchEvent *touch_event, void *context) {
// Latch the unified widget target for the whole gesture, mirroring the bridge route latch. Do
// NOT read latched_target during this Touchdown dispatch: it is set AFTER the manager processed
// the Touchdown and is only consumed by later events.
state->latched_target = prv_resolve_widget_target(state);
state->latched_target = prv_resolve_widget_target(state, touch_event);
state->declined = false;
if (state->latched_target) {
// Catch-to-stop: let the widget react to the bare Touchdown (e.g. stop a coasting fling)
Expand Down
47 changes: 43 additions & 4 deletions tests/fw/ui/recognizer/test_touch_nav.c
Original file line number Diff line number Diff line change
Expand Up @@ -749,13 +749,12 @@ void test_touch_nav__dead_zone_dropped(void) {
cl_assert_equal_i(prv_state(s_state.swipe), RecognizerState_Failed);
}

// Status-bar dead zone with exactly one registered widget (via the Swap registry) that is not
// under the active layer routes to that sole widget (Tier-1), not Dropped. Pins the sole-widget
// branch and the Swap-type registry walk that the y=90 registry tests never reach (they match on
// the parent walk before the dead-zone check).
// Status-bar dead zone with exactly one registered widget (via the Swap registry) that is not under the active layer but does lie under the dead-zone strip routes to that sole widget (Tier-1), not Dropped. Pins the sole-widget branch and the Swap-type registry walk that the y=90 registry tests never reach (they match on the parent walk before the dead-zone check).
void test_touch_nav__dead_zone_sole_widget_routes_tier1(void) {
static TouchNavWidgetNode node;
node = (TouchNavWidgetNode){0};
// The fallback is geometric: frame the widget so the dead-zone strip lies over it.
s_child_layer.frame = GRect(0, 0, 200, 200);
touch_nav_registry_add(&s_state, TouchNavWidgetType_Swap, &node, &s_child_layer, NULL, NULL);
s_active_layer = NULL; // the parent walk finds nothing, so the dead-zone branch runs

Expand All @@ -765,6 +764,20 @@ void test_touch_nav__dead_zone_sole_widget_routes_tier1(void) {
touch_nav_registry_remove(&s_state, TouchNavWidgetType_Swap, &node);
}

// The dead-zone fallback is geometric: a sole widget positioned away from the strip must not capture a dead-zone touch -- the touch is over chrome, not over the widget's content.
void test_touch_nav__dead_zone_sole_widget_away_drops(void) {
static TouchNavWidgetNode node;
node = (TouchNavWidgetNode){0};
s_child_layer.frame = GRect(0, 100, 200, 200);
touch_nav_registry_add(&s_state, TouchNavWidgetType_Swap, &node, &s_child_layer, NULL, NULL);
s_active_layer = NULL;

prv_dispatch(TouchEvent_Touchdown, 50, 4 /* inside the dead zone */, false);
cl_assert_equal_i(s_state.route, TouchNavRoute_Dropped);

touch_nav_registry_remove(&s_state, TouchNavWidgetType_Swap, &node);
}

// The dead-zone boundary is exclusive at TOUCH_NAV_STATUS_BAR_DEAD_ZONE_PX: one pixel inside drops,
// exactly at the threshold is a normal (Tier-2) route.
void test_touch_nav__dead_zone_boundary(void) {
Expand Down Expand Up @@ -1128,6 +1141,32 @@ void test_touch_nav__widget_can_start_decline_then_accept(void) {
touch_nav_registry_remove(&s_state, TouchNavWidgetType_Menu, &node);
}

// The dead-zone sole-widget fallback must actually drive the widget when it is migrated (ops-bearing): the router commits Tier-1, so the unified set has to latch the sole widget and keep driving the gesture after the finger leaves the dead zone. Otherwise both recognizer sets end up failed and the touch does nothing.
void test_touch_nav__dead_zone_routes_to_sole_migrated_widget(void) {
static TouchNavWidgetNode node;
prv_register_fake_widget(&node);
s_widget.can_start_result = true;
// The fallback is geometric: frame the widget so the dead-zone strip lies over it (the status bar swallows the hit, but the menu continues beneath it).
s_child_layer.frame = GRect(0, 0, 200, 200);
s_active_layer = NULL; // the parent walk finds nothing, so the dead-zone branch runs

prv_dispatch(TouchEvent_Touchdown, 50, 4 /* inside the dead zone */, false);
cl_assert_equal_i(s_state.route, TouchNavRoute_Tier1);
cl_assert_equal_p(s_state.latched_target, &node);

prv_advance_ms(20);
prv_dispatch(TouchEvent_PositionUpdate, 50, 55, false); // 51px down -> pan Starts
cl_assert_equal_i(s_widget.pan_started_calls, 1);
prv_advance_ms(20);
prv_dispatch(TouchEvent_PositionUpdate, 50, 90, false); // Updated -> live pan_update
cl_assert_equal_i(s_widget.pan_update_calls, 1);
prv_dispatch(TouchEvent_Liftoff, 0, 0, false); // Completed -> pan_snap
cl_assert_equal_i(s_widget.pan_snap_calls, 1);
cl_assert_equal_p(s_state.latched_target, NULL);

touch_nav_registry_remove(&s_state, TouchNavWidgetType_Menu, &node);
}

// A non-navigational (wake / DnD) Touchdown pre-empting an in-flight pan drops the gesture cleanly:
// the latch and `declined` are cleared and NO snap runs, so the pan is not committed. It does NOT run
// ops->pan_cancel -- the pan recognizer's cancel op returns false (pan.c) and it never
Expand Down