raylib: frame independent scroller (#36227)
* rm that * almost * yess * some work * more * todo * okay viber is good once in a while * temp * chadder can't do this * revert * this was broken anyway * fixes * mouse wheel scroll * some clean up * kinda works * way better * can tap to stop * more clean up * more clean up * revert last mouse * fix * debug only * no print * ahh setup.py fps doesn't affect DEFAULT_FPS ofc * rest * fix text * fix touch valid for networkpull/36207/head
parent
63e0e038fa
commit
5f33b2fb2d
9 changed files with 122 additions and 189 deletions
@ -1,189 +1,124 @@ |
|||||||
import time |
import math |
||||||
import pyray as rl |
import pyray as rl |
||||||
from collections import deque |
|
||||||
from enum import IntEnum |
from enum import IntEnum |
||||||
from openpilot.system.ui.lib.application import gui_app, MouseEvent, MousePos |
from openpilot.system.ui.lib.application import gui_app, MouseEvent |
||||||
|
from openpilot.common.filter_simple import FirstOrderFilter |
||||||
|
|
||||||
# Scroll constants for smooth scrolling behavior |
# Scroll constants for smooth scrolling behavior |
||||||
MOUSE_WHEEL_SCROLL_SPEED = 30 |
MOUSE_WHEEL_SCROLL_SPEED = 50 |
||||||
INERTIA_FRICTION = 0.92 # The rate at which the inertia slows down |
BOUNCE_RETURN_RATE = 5 # ~0.92 at 60fps |
||||||
MIN_VELOCITY = 0.5 # Minimum velocity before stopping the inertia |
MIN_VELOCITY = 2 # px/s, changes from auto scroll to steady state |
||||||
DRAG_THRESHOLD = 12 # Pixels of movement to consider it a drag, not a click |
MIN_VELOCITY_FOR_CLICKING = 2 * 60 # px/s, accepts clicks while auto scrolling below this velocity |
||||||
BOUNCE_FACTOR = 0.2 # Elastic bounce when scrolling past boundaries |
DRAG_THRESHOLD = 12 # pixels of movement to consider it a drag, not a click |
||||||
BOUNCE_RETURN_SPEED = 0.15 # How quickly it returns from the bounce |
|
||||||
MAX_BOUNCE_DISTANCE = 150 # Maximum distance for bounce effect |
DEBUG = False |
||||||
FLICK_MULTIPLIER = 1.8 # Multiplier for flick gestures |
|
||||||
VELOCITY_HISTORY_SIZE = 5 # Track velocity over multiple frames for smoother motion |
|
||||||
|
|
||||||
|
|
||||||
class ScrollState(IntEnum): |
class ScrollState(IntEnum): |
||||||
IDLE = 0 |
IDLE = 0 # Not dragging, content may be bouncing or scrolling with inertia |
||||||
DRAGGING_CONTENT = 1 |
DRAGGING_CONTENT = 1 # User is actively dragging the content |
||||||
DRAGGING_SCROLLBAR = 2 |
|
||||||
BOUNCING = 3 |
|
||||||
|
|
||||||
|
|
||||||
class GuiScrollPanel: |
class GuiScrollPanel: |
||||||
def __init__(self, show_vertical_scroll_bar: bool = False): |
def __init__(self): |
||||||
self._scroll_state: ScrollState = ScrollState.IDLE |
self._scroll_state: ScrollState = ScrollState.IDLE |
||||||
self._last_mouse_y: float = 0.0 |
self._last_mouse_y: float = 0.0 |
||||||
self._start_mouse_y: float = 0.0 # Track the initial mouse position for drag detection |
self._start_mouse_y: float = 0.0 # Track the initial mouse position for drag detection |
||||||
self._offset = rl.Vector2(0, 0) |
self._offset_filter_y = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) |
||||||
self._view = rl.Rectangle(0, 0, 0, 0) |
self._velocity_filter_y = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps) |
||||||
self._show_vertical_scroll_bar: bool = show_vertical_scroll_bar |
|
||||||
self._velocity_y = 0.0 # Velocity for inertia |
|
||||||
self._is_dragging: bool = False |
|
||||||
self._bounce_offset: float = 0.0 |
|
||||||
self._velocity_history: deque[float] = deque(maxlen=VELOCITY_HISTORY_SIZE) |
|
||||||
self._last_drag_time: float = 0.0 |
self._last_drag_time: float = 0.0 |
||||||
self._content_rect: rl.Rectangle | None = None |
|
||||||
self._bounds_rect: rl.Rectangle | None = None |
|
||||||
|
|
||||||
def handle_scroll(self, bounds: rl.Rectangle, content: rl.Rectangle) -> rl.Vector2: |
def update(self, bounds: rl.Rectangle, content: rl.Rectangle) -> float: |
||||||
# TODO: HACK: this class is driven by mouse events, so we need to ensure we have at least one event to process |
for mouse_event in gui_app.mouse_events: |
||||||
for mouse_event in gui_app.mouse_events or [MouseEvent(MousePos(0, 0), 0, False, False, False, time.monotonic())]: |
|
||||||
if mouse_event.slot == 0: |
if mouse_event.slot == 0: |
||||||
self._handle_mouse_event(mouse_event, bounds, content) |
self._handle_mouse_event(mouse_event, bounds, content) |
||||||
return self._offset |
|
||||||
|
|
||||||
def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, content: rl.Rectangle): |
self._update_state(bounds, content) |
||||||
# Store rectangles for reference |
|
||||||
self._content_rect = content |
|
||||||
self._bounds_rect = bounds |
|
||||||
|
|
||||||
max_scroll_y = max(content.height - bounds.height, 0) |
|
||||||
|
|
||||||
# Start dragging on mouse press |
|
||||||
if rl.check_collision_point_rec(mouse_event.pos, bounds) and mouse_event.left_pressed: |
|
||||||
if self._scroll_state == ScrollState.IDLE or self._scroll_state == ScrollState.BOUNCING: |
|
||||||
self._scroll_state = ScrollState.DRAGGING_CONTENT |
|
||||||
if self._show_vertical_scroll_bar: |
|
||||||
scrollbar_width = rl.gui_get_style(rl.GuiControl.LISTVIEW, rl.GuiListViewProperty.SCROLLBAR_WIDTH) |
|
||||||
scrollbar_x = bounds.x + bounds.width - scrollbar_width |
|
||||||
if mouse_event.pos.x >= scrollbar_x: |
|
||||||
self._scroll_state = ScrollState.DRAGGING_SCROLLBAR |
|
||||||
|
|
||||||
# TODO: hacky |
|
||||||
# when clicking while moving, go straight into dragging |
|
||||||
self._is_dragging = abs(self._velocity_y) > MIN_VELOCITY |
|
||||||
self._last_mouse_y = mouse_event.pos.y |
|
||||||
self._start_mouse_y = mouse_event.pos.y |
|
||||||
self._last_drag_time = mouse_event.t |
|
||||||
self._velocity_history.clear() |
|
||||||
self._velocity_y = 0.0 |
|
||||||
self._bounce_offset = 0.0 |
|
||||||
|
|
||||||
# Handle active dragging |
return float(self._offset_filter_y.x) |
||||||
if self._scroll_state == ScrollState.DRAGGING_CONTENT or self._scroll_state == ScrollState.DRAGGING_SCROLLBAR: |
|
||||||
if mouse_event.left_down: |
|
||||||
delta_y = mouse_event.pos.y - self._last_mouse_y |
|
||||||
|
|
||||||
# Track velocity for inertia |
def _update_state(self, bounds: rl.Rectangle, content: rl.Rectangle): |
||||||
time_since_last_drag = mouse_event.t - self._last_drag_time |
if DEBUG: |
||||||
if time_since_last_drag > 0: |
rl.draw_rectangle_lines(0, 0, abs(int(self._velocity_filter_y.x)), 10, rl.RED) |
||||||
# TODO: HACK: /2 since we usually get two touch events per frame |
|
||||||
drag_velocity = delta_y / time_since_last_drag / 60.0 / 2 # TODO: shouldn't be hardcoded |
|
||||||
self._velocity_history.append(drag_velocity) |
|
||||||
|
|
||||||
self._last_drag_time = mouse_event.t |
|
||||||
|
|
||||||
# Detect actual dragging |
|
||||||
total_drag = abs(mouse_event.pos.y - self._start_mouse_y) |
|
||||||
if total_drag > DRAG_THRESHOLD: |
|
||||||
self._is_dragging = True |
|
||||||
|
|
||||||
if self._scroll_state == ScrollState.DRAGGING_CONTENT: |
|
||||||
# Add resistance at boundaries |
|
||||||
if (self._offset.y > 0 and delta_y > 0) or (self._offset.y < -max_scroll_y and delta_y < 0): |
|
||||||
delta_y *= BOUNCE_FACTOR |
|
||||||
|
|
||||||
self._offset.y += delta_y |
|
||||||
elif self._scroll_state == ScrollState.DRAGGING_SCROLLBAR: |
|
||||||
scroll_ratio = content.height / bounds.height |
|
||||||
self._offset.y -= delta_y * scroll_ratio |
|
||||||
|
|
||||||
self._last_mouse_y = mouse_event.pos.y |
|
||||||
|
|
||||||
elif mouse_event.left_released: |
|
||||||
# Calculate flick velocity |
|
||||||
if self._velocity_history: |
|
||||||
total_weight = 0 |
|
||||||
weighted_velocity = 0.0 |
|
||||||
|
|
||||||
for i, v in enumerate(self._velocity_history): |
|
||||||
weight = i + 1 |
|
||||||
weighted_velocity += v * weight |
|
||||||
total_weight += weight |
|
||||||
|
|
||||||
if total_weight > 0: |
|
||||||
avg_velocity = weighted_velocity / total_weight |
|
||||||
self._velocity_y = avg_velocity * FLICK_MULTIPLIER |
|
||||||
|
|
||||||
# Check bounds |
|
||||||
if self._offset.y > 0 or self._offset.y < -max_scroll_y: |
|
||||||
self._scroll_state = ScrollState.BOUNCING |
|
||||||
else: |
|
||||||
self._scroll_state = ScrollState.IDLE |
|
||||||
|
|
||||||
# Handle mouse wheel |
# Handle mouse wheel |
||||||
wheel_move = rl.get_mouse_wheel_move() |
self._offset_filter_y.x += rl.get_mouse_wheel_move() * MOUSE_WHEEL_SCROLL_SPEED |
||||||
if wheel_move != 0: |
|
||||||
self._velocity_y = 0.0 |
max_scroll_distance = max(0, content.height - bounds.height) |
||||||
|
if self._scroll_state == ScrollState.IDLE: |
||||||
|
above_bounds, below_bounds = self._check_bounds(bounds, content) |
||||||
|
|
||||||
if self._show_vertical_scroll_bar: |
# Decay velocity when idle |
||||||
self._offset.y += wheel_move * (MOUSE_WHEEL_SCROLL_SPEED - 20) |
if abs(self._velocity_filter_y.x) > MIN_VELOCITY: |
||||||
rl.gui_scroll_panel(bounds, rl.ffi.NULL, content, self._offset, self._view) |
# 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: |
else: |
||||||
self._offset.y += wheel_move * MOUSE_WHEEL_SCROLL_SPEED |
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 |
||||||
|
|
||||||
if self._offset.y > 0 or self._offset.y < -max_scroll_y: |
elif self._scroll_state == ScrollState.DRAGGING_CONTENT: |
||||||
self._scroll_state = ScrollState.BOUNCING |
# Mouse not moving, decay velocity |
||||||
|
if not len(gui_app.mouse_events): |
||||||
|
self._velocity_filter_y.update(0.0) |
||||||
|
|
||||||
# Apply inertia (continue scrolling after mouse release) |
# 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 self._scroll_state == ScrollState.IDLE: |
||||||
if abs(self._velocity_y) > MIN_VELOCITY: |
if mouse_event.left_pressed: |
||||||
self._offset.y += self._velocity_y |
self._start_mouse_y = mouse_event.pos.y |
||||||
self._velocity_y *= INERTIA_FRICTION |
# 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 self._offset.y > 0 or self._offset.y < -max_scroll_y: |
if mouse_event.left_down: |
||||||
self._scroll_state = ScrollState.BOUNCING |
if abs(mouse_event.pos.y - self._start_mouse_y) > DRAG_THRESHOLD: |
||||||
else: |
self._scroll_state = ScrollState.DRAGGING_CONTENT |
||||||
self._velocity_y = 0.0 |
# Start velocity at initial measurement for more immediate response |
||||||
|
self._velocity_filter_y.initialized = False |
||||||
# Handle bouncing effect |
|
||||||
elif self._scroll_state == ScrollState.BOUNCING: |
elif self._scroll_state == ScrollState.DRAGGING_CONTENT: |
||||||
target_y = 0.0 |
if mouse_event.left_released: |
||||||
if self._offset.y < -max_scroll_y: |
|
||||||
target_y = -max_scroll_y |
|
||||||
|
|
||||||
distance = target_y - self._offset.y |
|
||||||
bounce_step = distance * BOUNCE_RETURN_SPEED |
|
||||||
self._offset.y += bounce_step |
|
||||||
self._velocity_y *= INERTIA_FRICTION * 0.8 |
|
||||||
|
|
||||||
if abs(distance) < 0.5 and abs(self._velocity_y) < MIN_VELOCITY: |
|
||||||
self._offset.y = target_y |
|
||||||
self._velocity_y = 0.0 |
|
||||||
self._scroll_state = ScrollState.IDLE |
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 |
||||||
|
|
||||||
# Limit bounce distance |
self._offset_filter_y.x += delta_y |
||||||
if self._scroll_state != ScrollState.DRAGGING_CONTENT: |
|
||||||
if self._offset.y > MAX_BOUNCE_DISTANCE: |
|
||||||
self._offset.y = MAX_BOUNCE_DISTANCE |
|
||||||
elif self._offset.y < -(max_scroll_y + MAX_BOUNCE_DISTANCE): |
|
||||||
self._offset.y = -(max_scroll_y + MAX_BOUNCE_DISTANCE) |
|
||||||
|
|
||||||
def is_touch_valid(self): |
# Track velocity for inertia |
||||||
return not self._is_dragging |
dt = mouse_event.t - self._last_drag_time |
||||||
|
if dt > 0: |
||||||
|
drag_velocity = delta_y / dt |
||||||
|
self._velocity_filter_y.update(drag_velocity) |
||||||
|
|
||||||
def get_normalized_scroll_position(self) -> float: |
# TODO: just store last mouse event! |
||||||
"""Returns the current scroll position as a value from 0.0 to 1.0""" |
self._last_drag_time = mouse_event.t |
||||||
if not self._content_rect or not self._bounds_rect: |
self._last_mouse_y = mouse_event.pos.y |
||||||
return 0.0 |
|
||||||
|
|
||||||
max_scroll_y = max(self._content_rect.height - self._bounds_rect.height, 0) |
def _check_bounds(self, bounds: rl.Rectangle, content: rl.Rectangle) -> tuple[bool, bool]: |
||||||
if max_scroll_y == 0: |
max_scroll_distance = max(0, content.height - bounds.height) |
||||||
return 0.0 |
above_bounds = self._offset_filter_y.x > 0 |
||||||
|
below_bounds = self._offset_filter_y.x < -max_scroll_distance |
||||||
|
return above_bounds, below_bounds |
||||||
|
|
||||||
normalized = -self._offset.y / max_scroll_y |
def is_touch_valid(self): |
||||||
return max(0.0, min(1.0, normalized)) |
return self._scroll_state == ScrollState.IDLE and abs(self._velocity_filter_y.x) < MIN_VELOCITY_FOR_CLICKING |
||||||
|
Loading…
Reference in new issue