diff --git a/system/ui/widgets/__init__.py b/system/ui/widgets/__init__.py index 097ac74c7e..a3fed6d962 100644 --- a/system/ui/widgets/__init__.py +++ b/system/ui/widgets/__init__.py @@ -100,6 +100,7 @@ class Widget(abc.ABC): if not self.is_visible: return None + self._layout() ret = self._render(self._rect) # Keep track of whether mouse down started within the widget's rectangle @@ -151,13 +152,16 @@ class Widget(abc.ABC): self.__is_pressed[mouse_event.slot] = False self._handle_mouse_event(mouse_event) - @abc.abstractmethod - def _render(self, rect: rl.Rectangle) -> bool | int | None: - """Render the widget within the given rectangle.""" + def _layout(self) -> None: + """Optionally lay out child widgets separately. This is called before rendering.""" def _update_state(self): """Optionally update the widget's non-layout state. This is called before rendering.""" + @abc.abstractmethod + def _render(self, rect: rl.Rectangle) -> bool | int | None: + """Render the widget within the given rectangle.""" + def _update_layout_rects(self) -> None: """Optionally update any layout rects on Widget rect change.""" diff --git a/system/ui/widgets/scroller.py b/system/ui/widgets/scroller.py index 2074de00b0..f33ba941bf 100644 --- a/system/ui/widgets/scroller.py +++ b/system/ui/widgets/scroller.py @@ -52,6 +52,11 @@ class Scroller(Widget): self._zoom_filter = FirstOrderFilter(1.0, 0.2, 1 / gui_app.target_fps) self._zoom_out_t: float = 0.0 + # layout state + self._visible_items: list[Widget] = [] + self._content_size: float = 0.0 + self._scroll_offset: float = 0.0 + self._item_pos_filter = BounceFilter(0.0, 0.05, 1 / gui_app.target_fps) # when not pressed, snap to closest item to be center @@ -160,28 +165,28 @@ class Scroller(Widget): return self.scroll_panel.get_offset() - def _render(self, _): - visible_items = [item for item in self._items if item.is_visible] + def _layout(self): + self._visible_items = [item for item in self._items if item.is_visible] # Add line separator between items if self._line_separator is not None: - l = len(visible_items) - for i in range(1, len(visible_items)): - visible_items.insert(l - i, self._line_separator) + l = len(self._visible_items) + for i in range(1, len(self._visible_items)): + self._visible_items.insert(l - i, self._line_separator) - content_size = sum(item.rect.width if self._horizontal else item.rect.height for item in visible_items) - content_size += self._spacing * (len(visible_items) - 1) - content_size += self._pad_start + self._pad_end + self._content_size = sum(item.rect.width if self._horizontal else item.rect.height for item in self._visible_items) + self._content_size += self._spacing * (len(self._visible_items) - 1) + self._content_size += self._pad_start + self._pad_end - scroll_offset = self._get_scroll(visible_items, content_size) + self._scroll_offset = self._get_scroll(self._visible_items, self._content_size) rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y), int(self._rect.width), int(self._rect.height)) - self._item_pos_filter.update(scroll_offset) + self._item_pos_filter.update(self._scroll_offset) cur_pos = 0 - for idx, item in enumerate(visible_items): + for idx, item in enumerate(self._visible_items): spacing = self._spacing if (idx > 0) else self._pad_start # Nicely lay out items horizontally/vertically if self._horizontal: @@ -195,29 +200,31 @@ class Scroller(Widget): # Consider scroll if self._horizontal: - x += scroll_offset + x += self._scroll_offset else: - y += scroll_offset + y += self._scroll_offset # Add some jello effect when scrolling if DO_JELLO: if self._horizontal: cx = self._rect.x + self._rect.width / 2 - jello_offset = scroll_offset - np.interp(x + item.rect.width / 2, - [self._rect.x, cx, self._rect.x + self._rect.width], - [self._item_pos_filter.x, scroll_offset, self._item_pos_filter.x]) + jello_offset = self._scroll_offset - np.interp(x + item.rect.width / 2, + [self._rect.x, cx, self._rect.x + self._rect.width], + [self._item_pos_filter.x, self._scroll_offset, self._item_pos_filter.x]) x -= np.clip(jello_offset, -20, 20) else: cy = self._rect.y + self._rect.height / 2 - jello_offset = scroll_offset - np.interp(y + item.rect.height / 2, - [self._rect.y, cy, self._rect.y + self._rect.height], - [self._item_pos_filter.x, scroll_offset, self._item_pos_filter.x]) + jello_offset = self._scroll_offset - np.interp(y + item.rect.height / 2, + [self._rect.y, cy, self._rect.y + self._rect.height], + [self._item_pos_filter.x, self._scroll_offset, self._item_pos_filter.x]) y -= np.clip(jello_offset, -20, 20) # Update item state item.set_position(round(x), round(y)) # round to prevent jumping when settling item.set_parent_rect(self._rect) + def _render(self, _): + for item in self._visible_items: # Skip rendering if not in viewport if not rl.check_collision_recs(item.rect, self._rect): continue @@ -227,17 +234,17 @@ class Scroller(Widget): if scale != 1.0: rl.rl_push_matrix() rl.rl_scalef(scale, scale, 1.0) - rl.rl_translatef((1 - scale) * (x + item.rect.width / 2) / scale, - (1 - scale) * (y + item.rect.height / 2) / scale, 0) + rl.rl_translatef((1 - scale) * (item.rect.x + item.rect.width / 2) / scale, + (1 - scale) * (item.rect.y + item.rect.height / 2) / scale, 0) item.render() rl.rl_pop_matrix() else: item.render() # Draw scroll indicator - if SCROLL_BAR and not self._horizontal and len(visible_items) > 0: - _real_content_size = content_size - self._rect.height + self._txt_scroll_indicator.height - scroll_bar_y = -scroll_offset / _real_content_size * self._rect.height + if SCROLL_BAR and not self._horizontal and len(self._visible_items) > 0: + _real_content_size = self._content_size - self._rect.height + self._txt_scroll_indicator.height + scroll_bar_y = -self._scroll_offset / _real_content_size * self._rect.height scroll_bar_y = min(max(scroll_bar_y, self._rect.y), self._rect.y + self._rect.height - self._txt_scroll_indicator.height) rl.draw_texture_ex(self._txt_scroll_indicator, rl.Vector2(self._rect.x, scroll_bar_y), 0, 1.0, rl.WHITE)