import pyray as rl from enum import IntEnum # Scroll constants for smooth scrolling behavior MOUSE_WHEEL_SCROLL_SPEED = 30 INERTIA_FRICTION = 0.92 # The rate at which the inertia slows down MIN_VELOCITY = 0.5 # Minimum velocity before stopping the inertia DRAG_THRESHOLD = 5 # Pixels of movement to consider it a drag, not a click BOUNCE_FACTOR = 0.2 # Elastic bounce when scrolling past boundaries BOUNCE_RETURN_SPEED = 0.15 # How quickly it returns from the bounce MAX_BOUNCE_DISTANCE = 150 # Maximum distance for bounce effect FLICK_MULTIPLIER = 1.8 # Multiplier for flick gestures VELOCITY_HISTORY_SIZE = 5 # Track velocity over multiple frames for smoother motion class ScrollState(IntEnum): IDLE = 0 DRAGGING_CONTENT = 1 DRAGGING_SCROLLBAR = 2 BOUNCING = 3 class GuiScrollPanel: def __init__(self, show_vertical_scroll_bar: bool = False): 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 = rl.Vector2(0, 0) self._view = rl.Rectangle(0, 0, 0, 0) 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._last_frame_time = rl.get_time() self._velocity_history: list[float] = [] 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: # Store rectangles for reference self._content_rect = content self._bounds_rect = bounds # Calculate time delta current_time = rl.get_time() delta_time = current_time - self._last_frame_time self._last_frame_time = current_time # Prevent large jumps delta_time = min(delta_time, 0.05) mouse_pos = rl.get_mouse_position() max_scroll_y = max(content.height - bounds.height, 0) # Start dragging on mouse press if rl.check_collision_point_rec(mouse_pos, bounds) and rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT): 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_pos.x >= scrollbar_x: self._scroll_state = ScrollState.DRAGGING_SCROLLBAR self._last_mouse_y = mouse_pos.y self._start_mouse_y = mouse_pos.y self._last_drag_time = current_time self._velocity_history = [] self._velocity_y = 0.0 self._bounce_offset = 0.0 self._is_dragging = False # Handle active dragging if self._scroll_state == ScrollState.DRAGGING_CONTENT or self._scroll_state == ScrollState.DRAGGING_SCROLLBAR: if rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT): delta_y = mouse_pos.y - self._last_mouse_y # Track velocity for inertia time_since_last_drag = current_time - self._last_drag_time if time_since_last_drag > 0: drag_velocity = delta_y / time_since_last_drag / 60.0 self._velocity_history.append(drag_velocity) if len(self._velocity_history) > VELOCITY_HISTORY_SIZE: self._velocity_history.pop(0) self._last_drag_time = current_time # Detect actual dragging total_drag = abs(mouse_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_pos.y elif rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT): # 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 wheel_move = rl.get_mouse_wheel_move() if wheel_move != 0: self._velocity_y = 0.0 if self._show_vertical_scroll_bar: self._offset.y += wheel_move * (MOUSE_WHEEL_SCROLL_SPEED - 20) rl.gui_scroll_panel(bounds, rl.ffi.NULL, content, self._offset, self._view) else: self._offset.y += wheel_move * MOUSE_WHEEL_SCROLL_SPEED if self._offset.y > 0 or self._offset.y < -max_scroll_y: self._scroll_state = ScrollState.BOUNCING # Apply inertia (continue scrolling after mouse release) if self._scroll_state == ScrollState.IDLE: if abs(self._velocity_y) > MIN_VELOCITY: self._offset.y += self._velocity_y self._velocity_y *= INERTIA_FRICTION if self._offset.y > 0 or self._offset.y < -max_scroll_y: self._scroll_state = ScrollState.BOUNCING else: self._velocity_y = 0.0 # Handle bouncing effect elif self._scroll_state == ScrollState.BOUNCING: target_y = 0.0 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 # Limit bounce distance 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) return self._offset def is_click_valid(self) -> bool: # Check if this is a click rather than a drag return ( self._scroll_state == ScrollState.IDLE and not self._is_dragging and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) ) def get_normalized_scroll_position(self) -> float: """Returns the current scroll position as a value from 0.0 to 1.0""" if not self._content_rect or not self._bounds_rect: return 0.0 max_scroll_y = max(self._content_rect.height - self._bounds_rect.height, 0) if max_scroll_y == 0: return 0.0 normalized = -self._offset.y / max_scroll_y return max(0.0, min(1.0, normalized))