You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
124 lines
5.1 KiB
124 lines
5.1 KiB
import math
|
|
import pyray as rl
|
|
from enum import IntEnum
|
|
from openpilot.system.ui.lib.application import gui_app, MouseEvent
|
|
from openpilot.common.filter_simple import FirstOrderFilter
|
|
|
|
# Scroll constants for smooth scrolling behavior
|
|
MOUSE_WHEEL_SCROLL_SPEED = 50
|
|
BOUNCE_RETURN_RATE = 5 # ~0.92 at 60fps
|
|
MIN_VELOCITY = 2 # px/s, changes from auto scroll to steady state
|
|
MIN_VELOCITY_FOR_CLICKING = 2 * 60 # px/s, accepts clicks while auto scrolling below this velocity
|
|
DRAG_THRESHOLD = 12 # pixels of movement to consider it a drag, not a click
|
|
|
|
DEBUG = False
|
|
|
|
|
|
class ScrollState(IntEnum):
|
|
IDLE = 0 # Not dragging, content may be bouncing or scrolling with inertia
|
|
DRAGGING_CONTENT = 1 # User is actively dragging the content
|
|
|
|
|
|
class GuiScrollPanel:
|
|
def __init__(self):
|
|
self._scroll_state: ScrollState = ScrollState.IDLE
|
|
self._last_mouse_y: float = 0.0
|
|
self._start_mouse_y: float = 0.0 # Track the initial mouse position for drag detection
|
|
self._offset_filter_y = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
|
self._velocity_filter_y = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps)
|
|
self._last_drag_time: float = 0.0
|
|
|
|
def update(self, bounds: rl.Rectangle, content: rl.Rectangle) -> float:
|
|
for mouse_event in gui_app.mouse_events:
|
|
if mouse_event.slot == 0:
|
|
self._handle_mouse_event(mouse_event, bounds, content)
|
|
|
|
self._update_state(bounds, content)
|
|
|
|
return float(self._offset_filter_y.x)
|
|
|
|
def _update_state(self, bounds: rl.Rectangle, content: rl.Rectangle):
|
|
if DEBUG:
|
|
rl.draw_rectangle_lines(0, 0, abs(int(self._velocity_filter_y.x)), 10, rl.RED)
|
|
|
|
# Handle mouse wheel
|
|
self._offset_filter_y.x += rl.get_mouse_wheel_move() * MOUSE_WHEEL_SCROLL_SPEED
|
|
|
|
max_scroll_distance = max(0, content.height - bounds.height)
|
|
if self._scroll_state == ScrollState.IDLE:
|
|
above_bounds, below_bounds = self._check_bounds(bounds, content)
|
|
|
|
# Decay velocity when idle
|
|
if abs(self._velocity_filter_y.x) > MIN_VELOCITY:
|
|
# Faster decay if bouncing back from out of bounds
|
|
friction = math.exp(-BOUNCE_RETURN_RATE * 1 / gui_app.target_fps)
|
|
self._velocity_filter_y.x *= friction ** 2 if (above_bounds or below_bounds) else friction
|
|
else:
|
|
self._velocity_filter_y.x = 0.0
|
|
|
|
if above_bounds or below_bounds:
|
|
if above_bounds:
|
|
self._offset_filter_y.update(0)
|
|
else:
|
|
self._offset_filter_y.update(-max_scroll_distance)
|
|
|
|
self._offset_filter_y.x += self._velocity_filter_y.x / gui_app.target_fps
|
|
|
|
elif self._scroll_state == ScrollState.DRAGGING_CONTENT:
|
|
# Mouse not moving, decay velocity
|
|
if not len(gui_app.mouse_events):
|
|
self._velocity_filter_y.update(0.0)
|
|
|
|
# Settle to exact bounds
|
|
if abs(self._offset_filter_y.x) < 1e-2:
|
|
self._offset_filter_y.x = 0.0
|
|
elif abs(self._offset_filter_y.x + max_scroll_distance) < 1e-2:
|
|
self._offset_filter_y.x = -max_scroll_distance
|
|
|
|
def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, content: rl.Rectangle):
|
|
if self._scroll_state == ScrollState.IDLE:
|
|
if mouse_event.left_pressed:
|
|
self._start_mouse_y = mouse_event.pos.y
|
|
# Interrupt scrolling with new drag
|
|
# TODO: stop scrolling with any tap, need to fix is_touch_valid
|
|
if abs(self._velocity_filter_y.x) > MIN_VELOCITY_FOR_CLICKING:
|
|
self._scroll_state = ScrollState.DRAGGING_CONTENT
|
|
# Start velocity at initial measurement for more immediate response
|
|
self._velocity_filter_y.initialized = False
|
|
|
|
if mouse_event.left_down:
|
|
if abs(mouse_event.pos.y - self._start_mouse_y) > DRAG_THRESHOLD:
|
|
self._scroll_state = ScrollState.DRAGGING_CONTENT
|
|
# Start velocity at initial measurement for more immediate response
|
|
self._velocity_filter_y.initialized = False
|
|
|
|
elif self._scroll_state == ScrollState.DRAGGING_CONTENT:
|
|
if mouse_event.left_released:
|
|
self._scroll_state = ScrollState.IDLE
|
|
else:
|
|
delta_y = mouse_event.pos.y - self._last_mouse_y
|
|
above_bounds, below_bounds = self._check_bounds(bounds, content)
|
|
# Rubber banding effect when out of bands
|
|
if above_bounds or below_bounds:
|
|
delta_y /= 3
|
|
|
|
self._offset_filter_y.x += delta_y
|
|
|
|
# Track velocity for inertia
|
|
dt = mouse_event.t - self._last_drag_time
|
|
if dt > 0:
|
|
drag_velocity = delta_y / dt
|
|
self._velocity_filter_y.update(drag_velocity)
|
|
|
|
# TODO: just store last mouse event!
|
|
self._last_drag_time = mouse_event.t
|
|
self._last_mouse_y = mouse_event.pos.y
|
|
|
|
def _check_bounds(self, bounds: rl.Rectangle, content: rl.Rectangle) -> tuple[bool, bool]:
|
|
max_scroll_distance = max(0, content.height - bounds.height)
|
|
above_bounds = self._offset_filter_y.x > 0
|
|
below_bounds = self._offset_filter_y.x < -max_scroll_distance
|
|
return above_bounds, below_bounds
|
|
|
|
def is_touch_valid(self):
|
|
return self._scroll_state == ScrollState.IDLE and abs(self._velocity_filter_y.x) < MIN_VELOCITY_FOR_CLICKING
|
|
|