diff --git a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py index 11adc304fb..3edd010d75 100644 --- a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -44,6 +44,10 @@ CAM_Y_OFFSET = 20 class BookmarkIcon(Widget): PEEK_THRESHOLD = 150 # If icon peeks out this much, snap it fully visible + FLICK_DISTANCE = 60 + FLICK_VELOCITY = 800 + FLICK_DURATION = 0.15 + FLICK_PAUSE = 0.06 FULL_VISIBLE_OFFSET = 200 # How far onscreen when fully visible HIDDEN_OFFSET = -50 # How far offscreen when hidden @@ -68,6 +72,9 @@ class BookmarkIcon(Widget): self._triggered_time: float = 0.0 self._triggered_duration = 1.5 self._swipe_expired = False + self._swipe_start_t = 0.0 + self._last_swipe_move = (0.0, 0.0) + self._swipe_moves = 0 def is_swiping_left(self) -> bool: """Check if currently swiping left (for scroller to disable).""" @@ -154,6 +161,9 @@ class BookmarkIcon(Widget): self._is_swiping = True self._is_swiping_left = False self._swipe_expired = False + self._swipe_start_t = mouse_event.t + self._last_swipe_move = (mouse_event.pos.x, mouse_event.t) + self._swipe_moves = 0 elif (mouse_event.left_down or mouse_event.left_released) and self._is_swiping: self._swipe_current_x = mouse_event.pos.x @@ -162,6 +172,12 @@ class BookmarkIcon(Widget): if swipe_offset < -12 or (vertical > 12 and vertical > abs(swipe_offset)): self.cancel_gesture() return + if mouse_event.left_down and not mouse_event.left_released: + if mouse_event.t <= self._last_swipe_move[1]: + self._swipe_moves = 0 + else: + self._swipe_moves += 1 + self._last_swipe_move = (mouse_event.pos.x, mouse_event.t) self._is_swiping_left = swipe_offset > 12 if self._is_swiping_left: self._interacting = True @@ -174,7 +190,14 @@ class BookmarkIcon(Widget): swipe_distance = self._swipe_start_x - self._swipe_current_x # If peeking past threshold, transition to animating to fully visible and bookmark - if swipe_distance > self.PEEK_THRESHOLD: + move_x, move_t = self._last_swipe_move + elapsed = move_t - self._swipe_start_t + held_distance = self._swipe_start_x - move_x + flick = (self._swipe_moves >= 2 and 0.01 <= elapsed <= self.FLICK_DURATION and + 0 <= mouse_event.t - move_t <= self.FLICK_PAUSE and + held_distance >= self.FLICK_DISTANCE and swipe_distance >= self.FLICK_DISTANCE and + swipe_distance >= held_distance - 12 and held_distance / elapsed >= self.FLICK_VELOCITY) + if swipe_distance > self.PEEK_THRESHOLD or flick: self._state = BookmarkState.TRIGGERED self._triggered_time = rl.get_time() self._triggered_duration = 1.5 diff --git a/openpilot/starpilot/ui/tests/test_bookmark_gesture.py b/openpilot/starpilot/ui/tests/test_bookmark_gesture.py index 5d13caeca9..5acf652f84 100644 --- a/openpilot/starpilot/ui/tests/test_bookmark_gesture.py +++ b/openpilot/starpilot/ui/tests/test_bookmark_gesture.py @@ -49,8 +49,8 @@ def navigation(monkeypatch): with patch.object(type(road.gui_app), 'frame', new_callable=PropertyMock, return_value=frame_number), \ patch.object(type(road.gui_app), 'mouse_events', new_callable=PropertyMock, return_value=list(events)): layout.render() - def event(x, y=100, *, pressed=False, down=False, released=False): - return MouseEvent(MousePos(x, y), 0, pressed, released, down, frame_number / 60) + def event(x, y=100, *, pressed=False, down=False, released=False, timestamp=None): + return MouseEvent(MousePos(x, y), 0, pressed, released, down, frame_number / 60 if timestamp is None else timestamp) with patch.object(road, 'ui_state', NS(started=True)), patch.object(bookmark, '_render'), \ patch.object(rl, 'begin_scissor_mode'), patch.object(rl, 'end_scissor_mode'), \ patch.object(rl, 'get_frame_time', return_value=1/60): @@ -125,3 +125,37 @@ def test_bookmark_requires_full_swipe_threshold(navigation, distance, expected): for event in (n.event(400, pressed=True, down=True), n.event(400-distance, released=True)): n.frame(event) assert n.callback.call_count == expected + + +@pytest.mark.parametrize('batched', [False, True]) +def test_short_fast_left_swipe_bookmarks_without_dragging_page(navigation, batched): + n = navigation + events = [n.event(400, pressed=True, down=True, timestamp=10), + n.event(360, down=True, timestamp=10.04), n.event(310, down=True, timestamp=10.08), + n.event(305, released=True, timestamp=10.09)] + if batched: + n.frame(*events) + else: + for event in events: + n.frame(event) + n.callback.assert_called_once() + assert n.page.rect.x == 0 + n.frame(n.event(305, released=True, timestamp=10.1)) + n.callback.assert_called_once() + + +@pytest.mark.parametrize('positions,times', [ + ([400, 360, 310, 305], [10, 10.4, 10.8, 10.81]), + ([400, 380, 360, 355], [10, 10.02, 10.04, 10.05]), + ([400, 390, 385, 300], [10, 10.02, 10.04, 10.05]), + ([400, 360, 310, 305], [10, 10.04, 10.08, 10.3]), + ([400, 360, 310, 330], [10, 10.04, 10.08, 10.09]), + ([400, 360, 310, 305], [10, 10, 10, 10]), + ([400, 360, 310, 305], [10, 9.9, 9.8, 9.7]), +]) +def test_short_swipe_rejects_slow_drag_jitter_release_jump_pause_reversal_and_bad_clock(navigation, positions, times): + n = navigation + for index, (x, timestamp) in enumerate(zip(positions, times, strict=True)): + n.frame(n.event(x, pressed=index == 0, down=index != 3, released=index == 3, timestamp=timestamp)) + n.callback.assert_not_called() + assert n.page.rect.x == 0