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