From b295a57281f3872ee81754ba421ab2284d9bc5e3 Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:12:53 -0400 Subject: [PATCH] mici ux --- .../ui/mici/layouts/settings/bluetooth.py | 4 + .../mici/layouts/settings/network/wifi_ui.py | 6 +- selfdrive/ui/mici/widgets/dialog.py | 2 +- system/ui/lib/scroll_panel2.py | 9 +- .../ui/lib/tests/test_scroller_interaction.py | 269 +++++++++++++++++- system/ui/widgets/__init__.py | 10 +- system/ui/widgets/scroller.py | 79 ++++- 7 files changed, 361 insertions(+), 18 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/bluetooth.py b/selfdrive/ui/mici/layouts/settings/bluetooth.py index b3a8b3d2f6..0eaa3422c3 100644 --- a/selfdrive/ui/mici/layouts/settings/bluetooth.py +++ b/selfdrive/ui/mici/layouts/settings/bluetooth.py @@ -79,6 +79,10 @@ class BluetoothDeviceButton(BigButton): super().set_touch_valid_callback(lambda: touch_callback() and not self._forget_btn.is_pressed) self._forget_btn.set_touch_valid_callback(touch_callback) + def set_touch_event_valid_callback(self, touch_callback): + super().set_touch_event_valid_callback(touch_callback) + self._forget_btn.set_touch_event_valid_callback(touch_callback) + def _draw_content(self, btn_y: float): self._label.set_color(LABEL_COLOR) label_rect = rl.Rectangle(self._rect.x + self.LABEL_PADDING, btn_y + self.LABEL_VERTICAL_PADDING, diff --git a/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py b/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py index 6a94f68b4b..33c459af1a 100644 --- a/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py @@ -7,7 +7,7 @@ from openpilot.common.swaglog import cloudlog from openpilot.selfdrive.ui.mici.layouts.settings.network.action_state import should_show_forget_button from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog, BigConfirmationDialog from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR -from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight +from openpilot.system.ui.lib.application import gui_app, MousePos, MouseEvent, FontWeight from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, SecurityType, normalize_ssid @@ -194,6 +194,10 @@ class WifiButton(BigButton): super().set_touch_valid_callback(lambda: touch_callback() and not self._forget_btn.is_pressed) self._forget_btn.set_touch_valid_callback(touch_callback) + def set_touch_event_valid_callback(self, touch_callback: Callable[[MouseEvent], bool]) -> None: + super().set_touch_event_valid_callback(touch_callback) + self._forget_btn.set_touch_event_valid_callback(touch_callback) + @property def _is_saved(self): return self._wifi_manager.is_connection_saved(self._network.ssid) diff --git a/selfdrive/ui/mici/widgets/dialog.py b/selfdrive/ui/mici/widgets/dialog.py index b23a83ecdd..35519f1322 100644 --- a/selfdrive/ui/mici/widgets/dialog.py +++ b/selfdrive/ui/mici/widgets/dialog.py @@ -349,7 +349,7 @@ class BigMultiOptionDialog(NavWidget): def _handle_mouse_event(self, mouse_event: MouseEvent) -> None: super()._handle_mouse_event(mouse_event) - if not self._scroll_inner.scroll_panel.is_touch_valid(): + if not self._scroll_inner.scroll_panel.is_event_touch_valid(mouse_event): self._can_click = False def _handle_mouse_release(self, mouse_pos: MousePos): diff --git a/system/ui/lib/scroll_panel2.py b/system/ui/lib/scroll_panel2.py index cf3c78a25e..21b3a5895f 100644 --- a/system/ui/lib/scroll_panel2.py +++ b/system/ui/lib/scroll_panel2.py @@ -72,9 +72,7 @@ class GuiScrollPanel2: bounds_size = bounds.width if self._horizontal else bounds.height - for mouse_event in gui_app.mouse_events: - self._handle_mouse_event(mouse_event, bounds, bounds_size, content_size) - self._previous_mouse_event = mouse_event + self._process_mouse_events(bounds, bounds_size, content_size) wheel_move = rl.get_mouse_wheel_move() if wheel_move != 0 and self.enabled and rl.check_collision_point_rec(rl.get_mouse_position(), bounds): @@ -93,6 +91,11 @@ class GuiScrollPanel2: print() return self.get_offset() + def _process_mouse_events(self, bounds: rl.Rectangle, bounds_size: float, content_size: float) -> None: + for mouse_event in gui_app.mouse_events: + self._handle_mouse_event(mouse_event, bounds, bounds_size, content_size) + self._previous_mouse_event = mouse_event + def _get_offset_bounds(self, bounds_size: float, content_size: float) -> tuple[float, float]: """Returns (max_offset, min_offset) for the given bounds and content size.""" return 0.0, min(0.0, bounds_size - content_size) diff --git a/system/ui/lib/tests/test_scroller_interaction.py b/system/ui/lib/tests/test_scroller_interaction.py index 8107bdc70d..85b96f98bc 100644 --- a/system/ui/lib/tests/test_scroller_interaction.py +++ b/system/ui/lib/tests/test_scroller_interaction.py @@ -4,7 +4,7 @@ import pytest from openpilot.system.ui import widgets from openpilot.system.ui.lib import scroll_panel2 from openpilot.system.ui.lib.application import MouseEvent, MousePos, gui_app -from openpilot.system.ui.widgets.scroller import _Scroller +from openpilot.system.ui.widgets.scroller import _Scroller, _MiciScrollPanel class Item(widgets.Widget): @@ -35,8 +35,9 @@ def make_scroller(monkeypatch): monkeypatch.setattr(widgets.device, "awake", True) monkeypatch.setattr(scroll_panel2, "TICI", True) - def make(**kwargs): - scroller = _Scroller([Item() for _ in range(8)], scroll_indicator=False, edge_shadows=False, **kwargs) + def make(items=None, **kwargs): + scroller = _Scroller(items if items is not None else [Item() for _ in range(8)], + scroll_indicator=False, edge_shadows=False, **kwargs) scroller.set_rect(rl.Rectangle(0, 0, 536, 240)) scroller.scroll_panel.set_offset(-500) scroller.render() @@ -95,12 +96,268 @@ def test_drag_outside_viewport_does_not_resume_snapping(make_scroller): @pytest.mark.parametrize("speed, expected_clicks", [(25, 1), (119, 1), (121, 0)]) -def test_inertial_scroll_click_threshold_is_unchanged(make_scroller, speed, expected_clicks): +@pytest.mark.parametrize("single_batch", [True, False]) +def test_inertial_scroll_click_threshold_is_unchanged(make_scroller, speed, expected_clicks, single_batch): scroller = make_scroller() scroller.scroll_panel._state = scroll_panel2.ScrollState.AUTO_SCROLL scroller.scroll_panel._velocity = -speed - frame(scroller, pressed=True) - frame(scroller, released=True, t=1.05) + if single_batch: + batch(scroller, event(pressed=True), event(released=True, t=1.05)) + else: + frame(scroller, pressed=True) + frame(scroller, released=True, t=1.05) assert sum(item.clicks for item in scroller.items) == expected_clicks + + +def event(x=200, y=100, *, pressed=False, released=False, slot=0, t=1.0): + return MouseEvent(MousePos(x, y), slot, pressed, released, not released, t) + + +def batch(scroller, *events): + gui_app._mouse_events = list(events) + scroller.render() + + +@pytest.mark.parametrize("horizontal", [True, False]) +@pytest.mark.parametrize("single_batch", [True, False]) +def test_drag_release_never_clicks(make_scroller, horizontal, single_batch): + scroller = make_scroller(horizontal=horizontal) + move = (lambda value, **kw: event(x=value, **kw)) if horizontal else (lambda value, **kw: event(y=value, **kw)) + start = 200 if horizontal else 100 + events = [move(start, pressed=True), move(start - 20, t=1.01), move(start - 40, t=1.21), + move(start - 40, released=True, t=1.22)] + if single_batch: + batch(scroller, *events) + else: + batch(scroller, events[0]) + batch(scroller, *events[1:]) + + assert sum(item.clicks for item in scroller.items) == 0 + assert not any(item.is_pressed for item in scroller.items) + + +@pytest.mark.parametrize("same_batch", [True, False]) +@pytest.mark.parametrize("pc", [True, False]) +def test_drag_then_tap_activates_only_the_tap(make_scroller, monkeypatch, same_batch, pc): + scroller = make_scroller() + monkeypatch.setattr(widgets, "PC", pc) + batch(scroller, event(pressed=True)) + drag = [event(180, t=1.01), event(160, t=1.21), event(160, released=True, t=1.22)] + tap = [event(160, pressed=True, t=1.30), event(160, released=True, t=1.35)] + if same_batch: + batch(scroller, *drag, *tap) + else: + batch(scroller, *drag) + batch(scroller, *tap) + + assert sum(item.clicks for item in scroller.items) == 1 + + +def test_tap_before_drag_in_same_batch_is_preserved(make_scroller): + scroller = make_scroller() + batch(scroller, event(pressed=True), event(released=True, t=1.05), + event(pressed=True, t=1.10), event(180, t=1.12)) + + assert sum(item.clicks for item in scroller.items) == 1 + assert scroller.scroll_panel.state == scroll_panel2.ScrollState.MANUAL_SCROLL + + +def test_batched_tap_cancels_programmatic_scroll(make_scroller): + scroller = make_scroller() + scroller.scroll_to(3, smooth=True) + batch(scroller, event(pressed=True), event(released=True, t=1.05)) + + assert not scroller.is_auto_scrolling + assert sum(item.clicks for item in scroller.items) == 1 + + +def test_secondary_touch_does_not_change_drag_or_velocity(make_scroller): + scroller = make_scroller() + batch(scroller, event(pressed=True), event(180, t=1.02)) + offset = scroller.scroll_panel.get_offset() + batch(scroller, event(400, slot=1, pressed=True, t=1.025), event(160, t=1.04), + event(420, slot=1, t=1.045), event(420, slot=1, released=True, t=1.05)) + + assert scroller.scroll_panel.state == scroll_panel2.ScrollState.MANUAL_SCROLL + assert scroller.scroll_panel.get_offset() == pytest.approx(offset - 20) + assert scroller.scroll_panel._velocity == pytest.approx(-1000) + + +def test_secondary_touch_does_not_cancel_primary_tap(make_scroller): + scroller = make_scroller() + batch(scroller, event(pressed=True), event(400, slot=1, pressed=True, t=1.01), + event(400, slot=1, released=True, t=1.02), event(released=True, t=1.05)) + + assert sum(item.clicks for item in scroller.items) == 1 + + +def test_outside_press_does_not_capture_coasting_panel(make_scroller): + scroller = make_scroller() + panel = scroller.scroll_panel + panel._state = scroll_panel2.ScrollState.AUTO_SCROLL + panel._velocity = -500 + offset = panel.get_offset() + batch(scroller, event(600, pressed=True), event(200, t=1.01), event(200, released=True, t=1.02)) + + assert panel.state == scroll_panel2.ScrollState.AUTO_SCROLL + assert panel.get_offset() == pytest.approx(offset - 500 / 60) + assert sum(item.clicks for item in scroller.items) == 0 + + +def test_outside_press_does_not_clear_snap_target(make_scroller): + scroller = make_scroller() + panel = scroller.scroll_panel + panel._state = scroll_panel2.ScrollState.AUTO_SCROLL + panel.snap_interval = 100 + panel._snap_target = -600 + batch(scroller, event(600, pressed=True)) + + assert panel._snap_target == -600 + assert panel.state == scroll_panel2.ScrollState.AUTO_SCROLL + + +def test_release_crossing_drag_threshold_does_not_leave_drag_active(make_scroller): + scroller = make_scroller() + batch(scroller, event(pressed=True), event(160, released=True, t=1.05)) + + assert sum(item.clicks for item in scroller.items) == 0 + assert scroller.scroll_panel.state == scroll_panel2.ScrollState.STEADY + batch(scroller, event(pressed=True, t=1.10), event(released=True, t=1.15)) + assert sum(item.clicks for item in scroller.items) == 1 + + +def test_disabling_scrolling_preserves_control_taps(make_scroller): + scroller = make_scroller() + scroller.set_scrolling_enabled(False) + offset = scroller.scroll_panel.get_offset() + batch(scroller, event(pressed=True), event(released=True, t=1.05)) + + assert sum(item.clicks for item in scroller.items) == 1 + assert scroller.scroll_panel.get_offset() == offset + + +@pytest.mark.parametrize("restart", ["show", "enable"]) +def test_interrupted_drag_does_not_poison_next_tap(make_scroller, restart): + scroller = make_scroller() + batch(scroller, event(pressed=True), event(180, t=1.02)) + if restart == "show": + scroller.set_reset_scroll_at_show(False) + scroller.hide_event() + scroller.show_event() + else: + scroller.set_enabled(False) + batch(scroller) + scroller.set_enabled(True) + batch(scroller, event(pressed=True, t=1.10), event(released=True, t=1.15)) + + assert sum(item.clicks for item in scroller.items) == 1 + + +def test_mici_panel_is_not_used_by_shared_panel_callers(make_scroller): + assert isinstance(make_scroller().scroll_panel, _MiciScrollPanel) + # Shared callers retain the original event handling, not Mici's slot filter. + panel = scroll_panel2.GuiScrollPanel2() + gui_app._mouse_events = [event(slot=1, pressed=True)] + panel.update(rl.Rectangle(0, 0, 536, 240), 1000) + assert panel.state == scroll_panel2.ScrollState.PRESSED + + +@pytest.mark.parametrize("kind", ["wifi", "bluetooth"]) +def test_nested_forget_button_rejects_swipe_and_accepts_next_tap(make_scroller, monkeypatch, kind): + from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiButton + from openpilot.selfdrive.ui.mici.layouts.settings.bluetooth import BluetoothDeviceButton + + cls = WifiButton if kind == "wifi" else BluetoothDeviceButton + button = object.__new__(cls) + widgets.Widget.__init__(button) + button.set_rect(rl.Rectangle(0, 0, 402, 180)) + button._grow_animation_until = None + button._shake_start = None + button._forget_btn = Item() + # Exercise the real callback forwarding and touch dispatch without network + # services or drawing; use the full card as the nested button's hit area. + monkeypatch.setattr(button, "_update_state", lambda: None) + monkeypatch.setattr(button, "_render", lambda rect: button._forget_btn.render(rect)) + monkeypatch.setattr(button, "_handle_mouse_release", lambda pos: None) + scroller = make_scroller(items=[Item(), button] + [Item() for _ in range(6)]) + batch(scroller, event(pressed=True)) + batch(scroller, event(180, t=1.01), event(160, t=1.21), event(160, released=True, t=1.22)) + assert button._forget_btn.clicks == 0 + + batch(scroller, event(160, pressed=True, t=1.30), event(160, released=True, t=1.35)) + assert button._forget_btn.clicks == 1 + + +@pytest.mark.parametrize("tap_after_drag", [False, True]) +def test_option_picker_uses_event_cancellation(make_scroller, monkeypatch, tap_after_drag): + from openpilot.selfdrive.ui.mici.widgets.dialog import BigMultiOptionDialog + from openpilot.system.ui.widgets.nav_widget import NavWidget + + scroller = make_scroller() + dialog = object.__new__(BigMultiOptionDialog) + widgets.Widget.__init__(dialog) + dialog.set_rect(scroller.rect) + dialog._scroll_inner = scroller + dialog._selected_option = "option" + scroller.items[0].option = "option" + selections = [] + monkeypatch.setattr(dialog, "_on_option_selected", selections.append) + monkeypatch.setattr(NavWidget, "_handle_mouse_event", lambda *args: None) + gui_app._mouse_events = [event(pressed=True)] + scroller.render() + dialog._process_mouse_events() + gui_app._mouse_events = [event(180, t=1.01), event(160, t=1.21), event(160, released=True, t=1.22)] + if tap_after_drag: + gui_app._mouse_events += [event(160, pressed=True, t=1.30), event(160, released=True, t=1.35)] + scroller.render() + dialog._process_mouse_events() + + assert selections == (["option"] if tap_after_drag else []) + + +@pytest.mark.parametrize("guard", ["disabled", "moving", "original_callback", "original_event_callback"]) +def test_existing_item_guards_still_reject_taps(make_scroller, monkeypatch, guard): + items = [Item() for _ in range(8)] + if guard == "original_callback": + items[1].set_touch_valid_callback(lambda: False) + elif guard == "original_event_callback": + items[1].set_touch_event_valid_callback(lambda ev: False) + scroller = make_scroller(items=items) + if guard == "disabled": + items[1].set_enabled(False) + elif guard == "moving": + monkeypatch.setattr(rl, "draw_rectangle_rec", lambda *args: None) + scroller.move_item(1, 2) + batch(scroller, event(pressed=True), event(released=True, t=1.05)) + + assert sum(item.clicks for item in items) == 0 + + +@pytest.mark.parametrize("horizontal", [True, False]) +def test_nav_scroller_swipe_to_dismiss_still_works(make_scroller, horizontal): + from openpilot.system.ui.widgets.scroller import NavScroller, NavRawScrollPanel + + class RawPanel(NavRawScrollPanel): + def _render(self, _): + pass + + nav = NavScroller() if horizontal else RawPanel() + nav.set_rect(rl.Rectangle(0, 0, 536, 240)) + if horizontal: + nav._scroller._show_scroll_indicator = False + nav._scroller._edge_shadows = False + nav._scroller.add_widgets([Item() for _ in range(8)]) + for ev in (event(y=50, pressed=True), event(y=150, t=1.1), event(y=150, released=True, t=1.15)): + gui_app._mouse_events = [ev] + # Same child-before-parent dispatch order as rendering, without drawing. + if horizontal: + nav._scroller.render(nav.rect) + else: + nav._scroll_panel.update(nav.rect, 1000) + nav._process_mouse_events() + + assert nav._playing_dismiss_animation + if horizontal: + assert sum(item.clicks for item in nav._scroller.items) == 0 diff --git a/system/ui/widgets/__init__.py b/system/ui/widgets/__init__.py index c3a8b8f82e..b0b645faf3 100644 --- a/system/ui/widgets/__init__.py +++ b/system/ui/widgets/__init__.py @@ -39,6 +39,7 @@ class Widget(abc.ABC): # if current mouse/touch down started within the widget's rectangle self.__tracking_is_pressed = [False] * MAX_TOUCH_SLOTS self._touch_valid_callback: Callable[[], bool] | None = None + self._touch_event_valid_callback: Callable[[MouseEvent], bool] | None = None self._click_delay: float | None = None # seconds to hold is_pressed after release self._click_release_time: float | None = None self._click_callback: Callable[[], None] | None = None @@ -91,6 +92,10 @@ class Widget(abc.ABC): """Check if the widget can be touched.""" return self._touch_valid_callback() if self._touch_valid_callback else True + def set_touch_event_valid_callback(self, touch_callback: Callable[[MouseEvent], bool]) -> None: + """Validate individual events when a parent has already processed the touch batch.""" + self._touch_event_valid_callback = touch_callback + def set_position(self, x: float, y: float) -> None: changed = (self._rect.x != x or self._rect.y != y) self._rect = rl.Rectangle(x, y, self._rect.width, self._rect.height) @@ -146,10 +151,11 @@ class Widget(abc.ABC): if not self._multi_touch and mouse_event.slot != 0: continue + event_touch_valid = touch_valid and (self._touch_event_valid_callback is None or self._touch_event_valid_callback(mouse_event)) mouse_in_rect = rl.check_collision_point_rec(mouse_event.pos, hit_rect) # Ignores touches/presses that start outside our rect # Allows touch to leave the rect and come back in focus if mouse did not release - if mouse_event.left_pressed and touch_valid: + if mouse_event.left_pressed and event_touch_valid: if mouse_in_rect: self._handle_mouse_press(mouse_event.pos) self.__is_pressed[mouse_event.slot] = True @@ -157,7 +163,7 @@ class Widget(abc.ABC): self._handle_mouse_event(mouse_event) # Callback such as scroll panel signifies user is scrolling - elif not touch_valid: + elif not event_touch_valid: self.__is_pressed[mouse_event.slot] = False self.__tracking_is_pressed[mouse_event.slot] = False diff --git a/system/ui/widgets/scroller.py b/system/ui/widgets/scroller.py index 8c4a7f1bdb..ec310da214 100644 --- a/system/ui/widgets/scroller.py +++ b/system/ui/widgets/scroller.py @@ -4,8 +4,8 @@ from collections.abc import Callable from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter from openpilot.common.swaglog import cloudlog -from openpilot.system.ui.lib.application import gui_app -from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2, ScrollState +from openpilot.system.ui.lib.application import gui_app, MouseEvent +from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2, ScrollState, MIN_DRAG_PIXELS from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.nav_widget import NavWidget @@ -25,6 +25,63 @@ DO_ZOOM = False DO_JELLO = False +class _MiciScrollPanel(GuiScrollPanel2): + """Mici gesture ownership, using the shared scrolling physics unchanged.""" + def __init__(self, horizontal: bool = True, handle_out_of_bounds: bool = True): + super().__init__(horizontal, handle_out_of_bounds) + self._event_touch_valid: dict[MouseEvent, bool] = {} + self.reset() + + def reset(self) -> None: + """Forget touch history without changing the scroll position.""" + self._state = ScrollState.STEADY + self._velocity = 0.0 + self._velocity_buffer.clear() + self._initial_click_event = None + self._previous_mouse_event = None + self._snap_target = None + self._touch_active = False + self.touch_started = False + self._event_touch_valid.clear() + + def is_event_touch_valid(self, event: MouseEvent) -> bool: + return self._event_touch_valid.get(event, False) + + def _process_mouse_events(self, bounds: rl.Rectangle, bounds_size: float, content_size: float) -> None: + self._event_touch_valid.clear() + self.touch_started = False + if not self.enabled: + self.reset() + # Disabling scrolling alone must not disable the controls inside it. + self._event_touch_valid.update((event, True) for event in gui_app.mouse_events if event.slot == 0) + return + + for event in gui_app.mouse_events: + if event.slot != 0: + continue + if event.left_pressed: + if not rl.check_collision_point_rec(event.pos, bounds): + continue + self._touch_active = True + self.touch_started = True + if not self._touch_active: + continue + + was_dragging = self._state == ScrollState.MANUAL_SCROLL + if self._state == ScrollState.PRESSED and self._initial_click_event is not None: + was_dragging |= abs(self._get_mouse_pos(event) - self._get_mouse_pos(self._initial_click_event)) > MIN_DRAG_PIXELS + super()._handle_mouse_event(event, bounds, bounds_size, content_size) + # Preserve drag cancellation through release, even if another tap follows + # in this batch. Children consume these events after the panel updates. + self._event_touch_valid[event] = not (was_dragging or self._state == ScrollState.MANUAL_SCROLL) + self._previous_mouse_event = event + if event.left_released: + self._touch_active = False + if self._state == ScrollState.MANUAL_SCROLL: + # A release can itself cross the drag threshold. Finish that drag now. + super()._handle_mouse_event(event, bounds, bounds_size, content_size) + + class ScrollIndicator(Widget): HORIZONTAL_MARGIN = 4 @@ -95,7 +152,7 @@ class _Scroller(Widget): # when not pressed, snap to closest item to be center self._scroll_snap_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps) - self.scroll_panel = GuiScrollPanel2(self._horizontal, handle_out_of_bounds=not self._snap_items) + self.scroll_panel = _MiciScrollPanel(self._horizontal, handle_out_of_bounds=not self._snap_items) self._scroll_enabled: bool | Callable[[], bool] = True self._show_scroll_indicator = scroll_indicator and self._horizontal @@ -148,9 +205,12 @@ class _Scroller(Widget): # preserve original touch valid callback original_touch_valid_callback = item._touch_valid_callback - item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled and not self._scrolling_to[1] + item.set_touch_valid_callback(lambda: self.enabled and not self._scrolling_to[1] and not self.moving_items and (original_touch_valid_callback() if original_touch_valid_callback else True)) + original_event_callback = item._touch_event_valid_callback + item.set_touch_event_valid_callback(lambda event: self.scroll_panel.is_event_touch_valid(event) and + (original_event_callback(event) if original_event_callback else True)) def add_widgets(self, items: list[Widget]) -> None: for item in items: @@ -189,6 +249,8 @@ class _Scroller(Widget): scroll_enabled = self._scroll_enabled() if callable(self._scroll_enabled) else self._scroll_enabled self.scroll_panel.set_enabled(scroll_enabled and self.enabled and not self._scrolling_to[1]) self.scroll_panel.update(self._rect, content_size) + if self.scroll_panel.touch_started and not self._scrolling_to[1]: + self._scrolling_to = None, False if not self._snap_items: return self.scroll_panel.get_offset() @@ -398,6 +460,7 @@ class _Scroller(Widget): def show_event(self): super().show_event() + self.scroll_panel.reset() for item in self._items: item.show_event() @@ -414,6 +477,7 @@ class _Scroller(Widget): def hide_event(self): super().hide_event() + self.scroll_panel.reset() for item in self._items: item.hide_event() @@ -450,12 +514,17 @@ class NavRawScrollPanel(NavWidget): def __init__(self): super().__init__() - self._scroll_panel = GuiScrollPanel2(horizontal=False) + self._scroll_panel = _MiciScrollPanel(horizontal=False) self._scroll_panel.set_enabled(lambda: self.enabled and not self.is_dismissing) def show_event(self): super().show_event() + self._scroll_panel.reset() self._scroll_panel.set_offset(0) + def hide_event(self): + super().hide_event() + self._scroll_panel.reset() + def _back_enabled(self) -> bool: return self._scroll_panel.get_offset() >= -20