|
3 | 3 | from collections.abc import Callable |
4 | 4 |
|
5 | 5 | from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter |
| 6 | +from openpilot.common.swaglog import cloudlog |
6 | 7 | from openpilot.system.ui.lib.application import gui_app |
7 | 8 | from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2, ScrollState |
8 | 9 | from openpilot.system.ui.widgets import Widget |
|
12 | 13 | LINE_PADDING = 40 |
13 | 14 | ANIMATION_SCALE = 0.6 |
14 | 15 |
|
| 16 | +MOVE_LIFT = 20 |
| 17 | +MOVE_OVERLAY_ALPHA = 0.65 |
| 18 | + |
15 | 19 | EDGE_SHADOW_WIDTH = 20 |
16 | 20 |
|
17 | 21 | MIN_ZOOM_ANIMATION_TIME = 0.075 # seconds |
@@ -95,6 +99,15 @@ def __init__(self, items: list[Widget], horizontal: bool = True, snap_items: boo |
95 | 99 | self._scroll_indicator = ScrollIndicator() |
96 | 100 | self._edge_shadows = edge_shadows and self._horizontal |
97 | 101 |
|
| 102 | + # move animation state |
| 103 | + # on move; lift src widget -> wait -> move all -> wait -> drop src widget |
| 104 | + self._overlay_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps) |
| 105 | + self._move_animations: dict[Widget, FirstOrderFilter] = {} |
| 106 | + self._move_lift: dict[Widget, FirstOrderFilter] = {} |
| 107 | + # these are used to wait before moving/dropping, also to move onto next part of the animation earlier for timing |
| 108 | + self._pending_lift: set[Widget] = set() |
| 109 | + self._pending_move: set[Widget] = set() |
| 110 | + |
98 | 111 | for item in items: |
99 | 112 | self.add_widget(item) |
100 | 113 |
|
@@ -123,7 +136,8 @@ def items(self) -> list[Widget]: |
123 | 136 |
|
124 | 137 | def add_widget(self, item: Widget) -> None: |
125 | 138 | self._items.append(item) |
126 | | - item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled) |
| 139 | + item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled and self._scrolling_to is None |
| 140 | + and not self.moving_items) |
127 | 141 |
|
128 | 142 | def set_scrolling_enabled(self, enabled: bool | Callable[[], bool]) -> None: |
129 | 143 | """Set whether scrolling is enabled (does not affect widget enabled state).""" |
@@ -197,6 +211,69 @@ def _get_scroll(self, visible_items: list[Widget], content_size: float) -> float |
197 | 211 |
|
198 | 212 | return self.scroll_panel.get_offset() |
199 | 213 |
|
| 214 | + @property |
| 215 | + def moving_items(self) -> bool: |
| 216 | + return len(self._move_animations) > 0 or len(self._move_lift) > 0 |
| 217 | + |
| 218 | + def move_item(self, from_idx: int, to_idx: int): |
| 219 | + assert self._horizontal |
| 220 | + if from_idx == to_idx: |
| 221 | + return |
| 222 | + |
| 223 | + if self.moving_items: |
| 224 | + cloudlog.warning(f"Already moving items, cannot move from {from_idx} to {to_idx}") |
| 225 | + return |
| 226 | + |
| 227 | + item = self._items.pop(from_idx) |
| 228 | + self._items.insert(to_idx, item) |
| 229 | + |
| 230 | + # store original position in content space of all affected widgets to animate from |
| 231 | + for idx in range(min(from_idx, to_idx), max(from_idx, to_idx) + 1): |
| 232 | + affected_item = self._items[idx] |
| 233 | + self._move_animations[affected_item] = FirstOrderFilter(affected_item.rect.x - self._scroll_offset, 0.15, 1 / gui_app.target_fps) |
| 234 | + self._pending_move.add(affected_item) |
| 235 | + |
| 236 | + # lift only src widget to make it more clear which one is moving |
| 237 | + self._move_lift[item] = FirstOrderFilter(0.0, 0.15, 1 / gui_app.target_fps) |
| 238 | + self._pending_lift.add(item) |
| 239 | + |
| 240 | + def _do_move_animation(self, item: Widget, target_x: float, target_y: float) -> tuple[float, float]: |
| 241 | + if item in self._move_lift: |
| 242 | + lift_filter = self._move_lift[item] |
| 243 | + |
| 244 | + # Animate lift |
| 245 | + if len(self._pending_move) > 0: |
| 246 | + lift_filter.update(MOVE_LIFT) |
| 247 | + # start moving when almost lifted |
| 248 | + if abs(lift_filter.x - MOVE_LIFT) < 2: |
| 249 | + self._pending_lift.discard(item) |
| 250 | + else: |
| 251 | + # if done moving, animate down |
| 252 | + lift_filter.update(0) |
| 253 | + if abs(lift_filter.x) < 1: |
| 254 | + del self._move_lift[item] |
| 255 | + target_y -= lift_filter.x |
| 256 | + |
| 257 | + # Animate move |
| 258 | + if item in self._move_animations: |
| 259 | + move_filter = self._move_animations[item] |
| 260 | + |
| 261 | + # compare/update in content space to match filter |
| 262 | + content_x = target_x - self._scroll_offset |
| 263 | + if len(self._pending_lift) == 0: |
| 264 | + move_filter.update(content_x) |
| 265 | + |
| 266 | + # drop when close to target |
| 267 | + if abs(move_filter.x - content_x) < 10: |
| 268 | + self._pending_move.discard(item) |
| 269 | + |
| 270 | + # finished moving |
| 271 | + if abs(move_filter.x - content_x) < 1: |
| 272 | + del self._move_animations[item] |
| 273 | + target_x = move_filter.x + self._scroll_offset |
| 274 | + |
| 275 | + return target_x, target_y |
| 276 | + |
200 | 277 | def _layout(self): |
201 | 278 | self._visible_items = [item for item in self._items if item.is_visible] |
202 | 279 |
|
@@ -242,30 +319,46 @@ def _layout(self): |
242 | 319 | [self._item_pos_filter.x, self._scroll_offset, self._item_pos_filter.x]) |
243 | 320 | y -= np.clip(jello_offset, -20, 20) |
244 | 321 |
|
| 322 | + # Animate moves if needed |
| 323 | + x, y = self._do_move_animation(item, x, y) |
| 324 | + |
245 | 325 | # Update item state |
246 | 326 | item.set_position(round(x), round(y)) # round to prevent jumping when settling |
247 | 327 | item.set_parent_rect(self._rect) |
248 | 328 |
|
| 329 | + def _render_item(self, item: Widget): |
| 330 | + # Skip rendering if not in viewport |
| 331 | + if not rl.check_collision_recs(item.rect, self._rect): |
| 332 | + return |
| 333 | + |
| 334 | + # Scale each element around its own origin when scrolling |
| 335 | + scale = self._zoom_filter.x |
| 336 | + if scale != 1.0: |
| 337 | + rl.rl_push_matrix() |
| 338 | + rl.rl_scalef(scale, scale, 1.0) |
| 339 | + rl.rl_translatef((1 - scale) * (item.rect.x + item.rect.width / 2) / scale, |
| 340 | + (1 - scale) * (item.rect.y + item.rect.height / 2) / scale, 0) |
| 341 | + item.render() |
| 342 | + rl.rl_pop_matrix() |
| 343 | + else: |
| 344 | + item.render() |
| 345 | + |
249 | 346 | def _render(self, _): |
250 | 347 | rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y), |
251 | 348 | int(self._rect.width), int(self._rect.height)) |
252 | 349 |
|
253 | 350 | for item in reversed(self._visible_items): |
254 | | - # Skip rendering if not in viewport |
255 | | - if not rl.check_collision_recs(item.rect, self._rect): |
| 351 | + if item in self._move_lift: |
256 | 352 | continue |
| 353 | + self._render_item(item) |
257 | 354 |
|
258 | | - # Scale each element around its own origin when scrolling |
259 | | - scale = self._zoom_filter.x |
260 | | - if scale != 1.0: |
261 | | - rl.rl_push_matrix() |
262 | | - rl.rl_scalef(scale, scale, 1.0) |
263 | | - rl.rl_translatef((1 - scale) * (item.rect.x + item.rect.width / 2) / scale, |
264 | | - (1 - scale) * (item.rect.y + item.rect.height / 2) / scale, 0) |
265 | | - item.render() |
266 | | - rl.rl_pop_matrix() |
267 | | - else: |
268 | | - item.render() |
| 355 | + # Dim background if moving items, lifted items are above |
| 356 | + self._overlay_filter.update(MOVE_OVERLAY_ALPHA if self.moving_items else 0.0) |
| 357 | + if self._overlay_filter.x > 0.01: |
| 358 | + rl.draw_rectangle_rec(self._rect, rl.Color(0, 0, 0, int(255 * self._overlay_filter.x))) |
| 359 | + |
| 360 | + for item in self._move_lift: |
| 361 | + self._render_item(item) |
269 | 362 |
|
270 | 363 | rl.end_scissor_mode() |
271 | 364 |
|
@@ -295,5 +388,10 @@ def show_event(self): |
295 | 388 |
|
296 | 389 | def hide_event(self): |
297 | 390 | super().hide_event() |
| 391 | + self._overlay_filter.x = 0.0 |
| 392 | + self._move_animations.clear() |
| 393 | + self._move_lift.clear() |
| 394 | + self._pending_lift.clear() |
| 395 | + self._pending_move.clear() |
298 | 396 | for item in self._items: |
299 | 397 | item.hide_event() |
0 commit comments