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.
		
		
		
		
		
			
		
			
				
					
					
						
							134 lines
						
					
					
						
							5.4 KiB
						
					
					
				
			
		
		
	
	
							134 lines
						
					
					
						
							5.4 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 rl.check_collision_point_rec(mouse_event.pos, bounds):
 | |
|         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
 | |
| 
 | |
|   def set_offset(self, position: float) -> None:
 | |
|     self._offset_filter_y.x = position
 | |
|     self._velocity_filter_y.x = 0.0
 | |
|     self._scroll_state = ScrollState.IDLE
 | |
| 
 | |
|   @property
 | |
|   def offset(self) -> float:
 | |
|     return float(self._offset_filter_y.x)
 | |
| 
 |