|
|
|
@ -1,16 +1,23 @@ |
|
|
|
|
import pyray as rl |
|
|
|
|
from enum import IntEnum |
|
|
|
|
|
|
|
|
|
# Scroll constants for smooth scrolling behavior |
|
|
|
|
MOUSE_WHEEL_SCROLL_SPEED = 30 |
|
|
|
|
INERTIA_FRICTION = 0.95 # The rate at which the inertia slows down |
|
|
|
|
MIN_VELOCITY = 0.1 # Minimum velocity before stopping the inertia |
|
|
|
|
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: |
|
|
|
@ -22,14 +29,33 @@ class GuiScrollPanel: |
|
|
|
|
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 = False |
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
# Handle dragging logic |
|
|
|
|
# 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: |
|
|
|
|
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) |
|
|
|
@ -38,51 +64,133 @@ class GuiScrollPanel: |
|
|
|
|
self._scroll_state = ScrollState.DRAGGING_SCROLLBAR |
|
|
|
|
|
|
|
|
|
self._last_mouse_y = mouse_pos.y |
|
|
|
|
self._start_mouse_y = mouse_pos.y # Record starting position |
|
|
|
|
self._velocity_y = 0.0 # Reset velocity when drag starts |
|
|
|
|
self._is_dragging = False # Reset dragging flag |
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
if self._scroll_state != ScrollState.IDLE: |
|
|
|
|
# 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 |
|
|
|
|
|
|
|
|
|
# Check if movement exceeds the drag threshold |
|
|
|
|
# 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: |
|
|
|
|
delta_y = -delta_y |
|
|
|
|
scroll_ratio = content.height / bounds.height |
|
|
|
|
self._offset.y -= delta_y * scroll_ratio |
|
|
|
|
|
|
|
|
|
self._last_mouse_y = mouse_pos.y |
|
|
|
|
self._velocity_y = delta_y # Update velocity during drag |
|
|
|
|
|
|
|
|
|
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 scrolling |
|
|
|
|
# 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 # Slow down velocity over time |
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
# Stop scrolling when velocity is low |
|
|
|
|
if abs(self._velocity_y) < MIN_VELOCITY: |
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
# Ensure scrolling doesn't go beyond bounds |
|
|
|
|
max_scroll_y = max(content.height - bounds.height, 0) |
|
|
|
|
self._offset.y = max(min(self._offset.y, 0), -max_scroll_y) |
|
|
|
|
# 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: |
|
|
|
|
return self._scroll_state == ScrollState.IDLE and not self._is_dragging and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) |
|
|
|
|
# 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)) |
|
|
|
|