|
16 | 16 | DO_ZOOM = False |
17 | 17 | DO_JELLO = False |
18 | 18 | SCROLL_BAR = False |
19 | | - |
| 19 | +SNAP_VELOCITY_THRESHOLD = 100.0 |
20 | 20 |
|
21 | 21 | class LineSeparator(Widget): |
22 | 22 | def __init__(self, height: int = 1): |
@@ -56,6 +56,7 @@ def __init__(self, items: list[Widget], horizontal: bool = True, snap_items: boo |
56 | 56 |
|
57 | 57 | # when not pressed, snap to closest item to be center |
58 | 58 | self._scroll_snap_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps) |
| 59 | + self._last_snap_target_offset: float = 0.0 |
59 | 60 |
|
60 | 61 | self.scroll_panel = GuiScrollPanel2(self._horizontal, handle_out_of_bounds=not self._snap_items) |
61 | 62 | self._scroll_enabled: bool | Callable[[], bool] = True |
@@ -123,40 +124,44 @@ def _get_scroll(self, visible_items: list[Widget], content_size: float) -> float |
123 | 124 | scroll_enabled = self._scroll_enabled() if callable(self._scroll_enabled) else self._scroll_enabled |
124 | 125 | self.scroll_panel.set_enabled(scroll_enabled and self.enabled) |
125 | 126 | self.scroll_panel.update(self._rect, content_size) |
126 | | - if not self._snap_items: |
127 | | - return self.scroll_panel.get_offset() |
128 | 127 |
|
129 | | - # Snap closest item to center |
130 | | - center_pos = self._rect.x + self._rect.width / 2 if self._horizontal else self._rect.y + self._rect.height / 2 |
131 | | - closest_delta_pos = float('inf') |
132 | | - scroll_snap_idx: int | None = None |
133 | | - for idx, item in enumerate(visible_items): |
134 | | - if self._horizontal: |
135 | | - delta_pos = (item.rect.x + item.rect.width / 2) - center_pos |
136 | | - else: |
137 | | - delta_pos = (item.rect.y + item.rect.height / 2) - center_pos |
138 | | - if abs(delta_pos) < abs(closest_delta_pos): |
139 | | - closest_delta_pos = delta_pos |
140 | | - scroll_snap_idx = idx |
141 | | - |
142 | | - if scroll_snap_idx is not None: |
143 | | - snap_item = visible_items[scroll_snap_idx] |
144 | | - if self.is_pressed: |
145 | | - # no snapping until released |
146 | | - self._scroll_snap_filter.x = 0 |
147 | | - else: |
148 | | - # TODO: this doesn't handle two small buttons at the edges well |
149 | | - if self._horizontal: |
150 | | - snap_delta_pos = (center_pos - (snap_item.rect.x + snap_item.rect.width / 2)) / 10 |
151 | | - snap_delta_pos = min(snap_delta_pos, -self.scroll_panel.get_offset() / 10) |
152 | | - snap_delta_pos = max(snap_delta_pos, (self._rect.width - self.scroll_panel.get_offset() - content_size) / 10) |
153 | | - else: |
154 | | - snap_delta_pos = (center_pos - (snap_item.rect.y + snap_item.rect.height / 2)) / 10 |
155 | | - snap_delta_pos = min(snap_delta_pos, -self.scroll_panel.get_offset() / 10) |
156 | | - snap_delta_pos = max(snap_delta_pos, (self._rect.height - self.scroll_panel.get_offset() - content_size) / 10) |
157 | | - self._scroll_snap_filter.update(snap_delta_pos) |
158 | | - |
159 | | - self.scroll_panel.set_offset(self.scroll_panel.get_offset() + self._scroll_snap_filter.x) |
| 128 | + current_offset = self.scroll_panel.get_offset() |
| 129 | + if not self._snap_items or not visible_items: |
| 130 | + return current_offset |
| 131 | + |
| 132 | + # Stable at target |
| 133 | + if self.scroll_panel.state == ScrollState.STEADY and abs(current_offset - self._last_snap_target_offset) < 1.0: |
| 134 | + self.scroll_panel.set_offset(self._last_snap_target_offset) # Ensure offset is exactly the target |
| 135 | + return self._last_snap_target_offset |
| 136 | + |
| 137 | + # Interaction or fast fling disables snapping |
| 138 | + is_interacting = self.is_pressed or self.scroll_panel.state == ScrollState.PRESSED |
| 139 | + is_flinging = (self.scroll_panel.state == ScrollState.AUTO_SCROLL and abs(self.scroll_panel._velocity) > SNAP_VELOCITY_THRESHOLD) |
| 140 | + if is_interacting or is_flinging: |
| 141 | + self._scroll_snap_filter.x = current_offset # Reset filter state to current offset during active motion |
| 142 | + return current_offset |
| 143 | + |
| 144 | + # Viewport center |
| 145 | + if self._horizontal: |
| 146 | + viewport_size, viewport_center = self._rect.width, self._rect.x + self._rect.width / 2 |
| 147 | + def get_center(r): return r.x + r.width / 2 |
| 148 | + else: |
| 149 | + viewport_size, viewport_center = self._rect.height, self._rect.y + self._rect.height / 2 |
| 150 | + def get_center(r): return r.y + r.height / 2 |
| 151 | + |
| 152 | + # Find closest item to center |
| 153 | + snap_item = min(visible_items, key=lambda w: abs(get_center(w.rect) - viewport_center)) |
| 154 | + item_center = get_center(snap_item.rect) - current_offset |
| 155 | + target_offset = viewport_center - item_center |
| 156 | + |
| 157 | + # Clamp offset |
| 158 | + min_scroll = -(content_size - viewport_size) |
| 159 | + target_offset = max(min_scroll, min(0.0, target_offset)) |
| 160 | + |
| 161 | + # Smooth snap |
| 162 | + self._scroll_snap_filter.update(target_offset) |
| 163 | + self._last_snap_target_offset = target_offset |
| 164 | + self.scroll_panel.set_offset(self._scroll_snap_filter.x) |
160 | 165 |
|
161 | 166 | return self.scroll_panel.get_offset() |
162 | 167 |
|
|
0 commit comments