diff --git a/selfdrive/ui/sunnypilot/mici/layouts/main.py b/selfdrive/ui/sunnypilot/mici/layouts/main.py index b0401e8119..5bbe965c9d 100644 --- a/selfdrive/ui/sunnypilot/mici/layouts/main.py +++ b/selfdrive/ui/sunnypilot/mici/layouts/main.py @@ -6,8 +6,14 @@ See the LICENSE.md file in the root directory for more details. """ from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout +from openpilot.selfdrive.ui.sunnypilot.mici.widgets.scroll_panel_sp import GuiScrollPanel2SP class MiciMainLayoutSP(MiciMainLayout): + def __init__(self): + super().__init__() + scroller = self._scroller + scroller.scroll_panel = GuiScrollPanel2SP(scroller._horizontal, handle_out_of_bounds=not scroller._snap_items) + def _should_auto_scroll_to_onroad(self) -> bool: return not self._onroad_layout.is_on_info_panel() diff --git a/selfdrive/ui/sunnypilot/mici/layouts/onroad_info_panel.py b/selfdrive/ui/sunnypilot/mici/layouts/onroad_info_panel.py index c51b8c5f41..521801fb2e 100644 --- a/selfdrive/ui/sunnypilot/mici/layouts/onroad_info_panel.py +++ b/selfdrive/ui/sunnypilot/mici/layouts/onroad_info_panel.py @@ -10,10 +10,9 @@ from dataclasses import dataclass from openpilot.common.constants import CV from openpilot.common.filter_simple import FirstOrderFilter from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.text_measure import measure_text_cached -from openpilot.system.ui.lib.application import MousePos from openpilot.system.ui.widgets import Widget from openpilot.selfdrive.ui.mici.onroad.alert_renderer import AlertRenderer from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import BookmarkIcon @@ -21,6 +20,26 @@ from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import BookmarkIcon METER_TO_KM = 0.001 METER_TO_MILE = 0.000621371 +CONTENT_MARGIN = 16 +SPEED_LIMIT_SIGN_WIDTH = 146 +VIENNA_SIGN_SIZE = 146 +MUTCD_SIGN_HEIGHT = 178 +OFFSET_BADGE_SIZE = 50 +OFFSET_BADGE_PANEL_PADDING = 4 +MUTCD_OFFSET_SIGN_Y_SHIFT = 6 +VIENNA_BADGE_X_RATIO = 0.80 +VIENNA_BADGE_UPCOMING_X_RATIO = 0.70 +VIENNA_BADGE_Y_RATIO = -0.82 +UPCOMING_SIGN_SIZE_RATIO = 0.76 +UPCOMING_SIGN_OVERLAP_RATIO = 0.05 +UNIT_FONT_SIZE = 40 +SPEED_FONT_SIZE = 114 +ROAD_FONT_SIZE = 32 +SCC_TAG_WIDTH = 78 +SCC_TAG_HEIGHT = 30 +SCC_TAG_GAP = 5 +COLUMN_GAP = 12 + @dataclass(frozen=True) class OnroadInfoPanelColors: @@ -107,10 +126,8 @@ class OnroadInfoPanel(Widget): self._update_state() rl.draw_rectangle(int(rect.x), int(rect.y), int(rect.width), int(rect.height), COLORS.bg_dark) - margin = 20 - mid_y = rect.y + rect.height / 2 - left_x = rect.x + margin + left_x = rect.x + CONTENT_MARGIN if self.cruise_enabled: unit = tr("MAX") @@ -119,81 +136,90 @@ class OnroadInfoPanel(Widget): unit = tr("km/h") if ui_state.is_metric else tr("MPH") display_speed = self.current_speed - speed_val = str(round(display_speed)) + display_speed_text = str(round(display_speed)) if self.speed_limit_valid and display_speed > self.speed_limit: speed_color = COLORS.red else: speed_color = COLORS.white - rl.draw_text_ex(self._font_semi_bold, unit, rl.Vector2(left_x, mid_y - 95), 38, 0, COLORS.grey) - rl.draw_text_ex(self._font_bold, speed_val, rl.Vector2(left_x, mid_y - 60), 110, 0, speed_color) + sign_width = min(SPEED_LIMIT_SIGN_WIDTH, rect.width * 0.30) + sign_height = VIENNA_SIGN_SIZE if ui_state.is_metric else MUTCD_SIGN_HEIGHT - sign_width = 135 - sign_height = 135 if ui_state.is_metric else 175 - - has_next = self.next_speed_limit > 0 and self.next_speed_limit != self.speed_limit - target_slide = 1.0 if has_next else 0.0 + has_upcoming_limit = self.next_speed_limit > 0 and self.next_speed_limit != self.speed_limit + target_sign_slide = 1.0 if has_upcoming_limit else 0.0 slide_speed = 3.0 * rl.get_frame_time() - if self._sign_slide < target_slide: - self._sign_slide = min(self._sign_slide + slide_speed, target_slide) - elif self._sign_slide > target_slide: - self._sign_slide = max(self._sign_slide - slide_speed, target_slide) + if self._sign_slide < target_sign_slide: + self._sign_slide = min(self._sign_slide + slide_speed, target_sign_slide) + elif self._sign_slide > target_sign_slide: + self._sign_slide = max(self._sign_slide - slide_speed, target_sign_slide) - next_w = int(sign_width * 0.7) - next_h = int(sign_height * 0.7) - next_peek = int(next_w * 0.85) + 5 - centered_x = rect.x + rect.width - sign_width - margin - shifted_x = rect.x + rect.width - sign_width - margin - next_peek - sign_x = centered_x + (shifted_x - centered_x) * self._sign_slide + upcoming_width = int(sign_width * UPCOMING_SIGN_SIZE_RATIO) + upcoming_height = int(sign_height * UPCOMING_SIGN_SIZE_RATIO) + upcoming_reserved_width = int(upcoming_width * 0.85) + 5 + sign_x_without_upcoming = rect.x + rect.width - sign_width - CONTENT_MARGIN + sign_x_with_upcoming = rect.x + rect.width - sign_width - CONTENT_MARGIN - upcoming_reserved_width + sign_x = sign_x_without_upcoming + (sign_x_with_upcoming - sign_x_without_upcoming) * self._sign_slide sign_y = rect.y + (rect.height - sign_height) / 2 + if not ui_state.is_metric and self.speed_limit_offset != 0 and self.speed_limit_valid: + sign_y += MUTCD_OFFSET_SIGN_Y_SHIFT - road_y = mid_y + 55 - road_width = sign_x - left_x - margin - self._draw_road_name(left_x, road_y, road_width) + readout_right = sign_x - COLUMN_GAP + readout_width = max(1, readout_right - left_x) + road_y = rect.y + rect.height - 44 - if has_next and self._sign_slide > 0.01: - next_val = str(round(self.next_speed_limit)) - dist_str = self._format_distance(self.next_speed_limit_distance) - next_x = sign_x + sign_width - int(next_w * 0.15) - next_y = sign_y + (sign_height - next_h) / 2 + unit_font_size = self._fit_font_size(self._font_semi_bold, unit, readout_width, 46, UNIT_FONT_SIZE, 28) + speed_font_size = self._fit_font_size(self._font_bold, display_speed_text, readout_width, road_y - (rect.y + 54) - 8, + SPEED_FONT_SIZE, 76) + speed_size = measure_text_cached(self._font_bold, display_speed_text, speed_font_size) + speed_y = min(rect.y + 54, road_y - speed_size.y - 8) + unit_y = max(rect.y + 14, speed_y - unit_font_size - 6) - next_speed_color = COLORS.black + rl.draw_text_ex(self._font_semi_bold, unit, rl.Vector2(left_x, unit_y), unit_font_size, 0, COLORS.grey) + rl.draw_text_ex(self._font_bold, display_speed_text, rl.Vector2(left_x, speed_y), speed_font_size, 0, speed_color) + self._draw_road_name(left_x, road_y, readout_width) + + if has_upcoming_limit and self._sign_slide > 0.01: + upcoming_speed_text = str(round(self.next_speed_limit)) + distance_text = self._format_distance(self.next_speed_limit_distance) + upcoming_x = sign_x + sign_width - int(upcoming_width * UPCOMING_SIGN_OVERLAP_RATIO) + upcoming_y = sign_y + (sign_height - upcoming_height) / 2 + + upcoming_speed_color = COLORS.black if ui_state.is_metric: - self._draw_vienna_sign(next_x, next_y, next_w, next_h, next_val, next_speed_color, is_upcoming=True) + self._draw_vienna_sign(upcoming_x, upcoming_y, upcoming_width, upcoming_height, upcoming_speed_text, upcoming_speed_color, is_upcoming=True) else: - self._draw_mutcd_sign(next_x, next_y, next_w, next_h, next_val, next_speed_color, is_upcoming=True) + self._draw_mutcd_sign(upcoming_x, upcoming_y, upcoming_width, upcoming_height, upcoming_speed_text, upcoming_speed_color, is_upcoming=True) - dist_size = measure_text_cached(self._font_medium, dist_str, 24) - rl.draw_text_ex(self._font_medium, dist_str, rl.Vector2(next_x + next_w / 2 - dist_size.x / 2, next_y + next_h + 4), 24, 0, COLORS.grey) + distance_font_size = self._fit_font_size(self._font_medium, distance_text, upcoming_width, 30, 24, 16) + distance_size = measure_text_cached(self._font_medium, distance_text, distance_font_size) + rl.draw_text_ex(self._font_medium, distance_text, rl.Vector2(upcoming_x + upcoming_width / 2 - distance_size.x / 2, upcoming_y + upcoming_height), + distance_font_size, 0, COLORS.grey) self._draw_speed_limit_sign(sign_x, sign_y, sign_width, sign_height) if self.speed_limit_offset != 0 and self.speed_limit_valid: - offset_val = str(abs(round(self.speed_limit_offset))) - badge_sz = 42 - badge_x = sign_x + sign_width - badge_sz * 0.85 - badge_y = sign_y - badge_sz * 0.25 + offset_text = str(abs(round(self.speed_limit_offset))) + badge_size = OFFSET_BADGE_SIZE + badge_rect = self._offset_badge_rect(rect, sign_x, sign_y, sign_width, sign_height, badge_size, has_upcoming_limit) if ui_state.is_metric: - badge_r = badge_sz / 2 - badge_cx = badge_x + badge_r - badge_cy = badge_y + badge_r - rl.draw_circle(int(badge_cx), int(badge_cy), badge_r + 2, COLORS.dark_grey) - rl.draw_circle(int(badge_cx), int(badge_cy), badge_r, COLORS.badge_bg) - self._draw_text_centered(self._font_bold, offset_val, 24, rl.Vector2(badge_cx, badge_cy), COLORS.white) + badge_radius = badge_size / 2 + badge_center_x = badge_rect.x + badge_radius + badge_center_y = badge_rect.y + badge_radius + rl.draw_circle(int(badge_center_x), int(badge_center_y), badge_radius + 2, COLORS.dark_grey) + rl.draw_circle(int(badge_center_x), int(badge_center_y), badge_radius, COLORS.badge_bg) + self._draw_text_centered_fit(self._font_bold, offset_text, 32, rl.Vector2(badge_center_x, badge_center_y), COLORS.white, + badge_size - 10, badge_size - 8, min_size=24) else: - mutcd_badge_x = sign_x + sign_width - badge_sz * 0.65 - mutcd_badge_y = sign_y - badge_sz * 0.50 - badge_rect = rl.Rectangle(mutcd_badge_x, mutcd_badge_y, badge_sz, badge_sz) rl.draw_rectangle_rounded(badge_rect, 0.25, 10, COLORS.badge_bg) rl.draw_rectangle_rounded_lines_ex(badge_rect, 0.25, 10, 2, COLORS.dark_grey) - self._draw_text_centered(self._font_bold, offset_val, 24, rl.Vector2(mutcd_badge_x + badge_sz / 2, mutcd_badge_y + badge_sz / 2), COLORS.white) + self._draw_text_centered_fit(self._font_bold, offset_text, 32, rl.Vector2(badge_rect.x + badge_size / 2, badge_rect.y + badge_size / 2), + COLORS.white, badge_size - 10, badge_size - 8, min_size=24) - # SCC - speed_size = measure_text_cached(self._font_bold, speed_val, 110) - scc_x = left_x + speed_size.x + 30 - scc_y = mid_y - 50 - self._draw_scc_icons(scc_x, scc_y) + scc_tag_x = min(left_x + speed_size.x + COLUMN_GAP, readout_right - SCC_TAG_WIDTH) + scc_tag_y = speed_y + (speed_size.y - (SCC_TAG_HEIGHT * 2 + SCC_TAG_GAP)) / 2 + if scc_tag_x >= left_x + speed_size.x + 8: + self._draw_scc_icons(scc_tag_x, scc_tag_y, readout_right) self._bookmark_icon.render(rect) @@ -205,23 +231,24 @@ class OnroadInfoPanel(Widget): rl.draw_rectangle(int(rect.x), int(rect.y), int(rect.width), int(rect.height), rl.Color(0, 0, 0, int(150 * alpha))) self._alert_renderer.render(rect) - def _draw_scc_icons(self, x: float, y: float) -> None: + def _draw_scc_icons(self, x: float, y: float, right_limit: float) -> None: sm = ui_state.sm if not sm.valid["longitudinalPlanSP"]: return scc = sm["longitudinalPlanSP"].smartCruiseControl - box_w, box_h = 100, 36 - gap = 6 drawn = 0 for label, active in [("SCC-V", scc.vision.active), ("SCC-M", scc.map.active)]: if not active: continue - bx = x - by = y + drawn * (box_h + gap) - rl.draw_rectangle_rounded(rl.Rectangle(bx, by, box_w, box_h), 0.3, 10, COLORS.green) - self._draw_text_centered(self._font_bold, label, 20, rl.Vector2(bx + box_w / 2, by + box_h / 2), COLORS.black) + tag_x = x + if tag_x + SCC_TAG_WIDTH > right_limit: + return + tag_y = y + drawn * (SCC_TAG_HEIGHT + SCC_TAG_GAP) + rl.draw_rectangle_rounded(rl.Rectangle(tag_x, tag_y, SCC_TAG_WIDTH, SCC_TAG_HEIGHT), 0.3, 10, COLORS.green) + self._draw_text_centered_fit(self._font_bold, label, 18, rl.Vector2(tag_x + SCC_TAG_WIDTH / 2, tag_y + SCC_TAG_HEIGHT / 2), COLORS.black, + SCC_TAG_WIDTH - 10, SCC_TAG_HEIGHT - 4, min_size=14) drawn += 1 def _draw_speed_limit_sign(self, x: float, y: float, sign_width: float, sign_height: float) -> None: @@ -234,8 +261,11 @@ class OnroadInfoPanel(Widget): self._draw_mutcd_sign(x, y, sign_width, sign_height, speed_str, speed_color, is_upcoming=False) def _draw_road_name(self, x: float, y: float, width: float) -> None: + if width <= 0: + return + road_display = self.road_name if self.road_name else "--" - font_size = 30 + font_size = self._fit_font_size(self._font_semi_bold, road_display, width, 38, ROAD_FONT_SIZE, 28) road_size = measure_text_cached(self._font_semi_bold, road_display, font_size) text_width = road_size.x @@ -276,9 +306,7 @@ class OnroadInfoPanel(Widget): rl.draw_ring(center, outer_radius - ring_width, outer_radius, 0, 360, 36, COLORS.red) font_size = outer_radius * (0.7 if len(speed_str) >= 3 else 0.9) - text_size = measure_text_cached(self._font_bold, speed_str, int(font_size)) - text_pos = rl.Vector2(center.x - text_size.x / 2, center.y - text_size.y / 2) - rl.draw_text_ex(self._font_bold, speed_str, text_pos, font_size, 0, speed_color) + self._draw_text_centered_fit(self._font_bold, speed_str, int(font_size), center, speed_color, width * 0.72, height * 0.50, min_size=24) def _draw_mutcd_sign(self, x: float, y: float, width: float, height: float, speed_str: str, speed_color: rl.Color, is_upcoming: bool = False) -> None: sign_rect = rl.Rectangle(x, y, width, height) @@ -294,18 +322,69 @@ class OnroadInfoPanel(Widget): mid_x = x + width / 2 label_size = max(18, int(width * 0.26)) if is_upcoming: - self._draw_text_centered(self._font_bold, tr("AHEAD"), label_size, rl.Vector2(mid_x, y + height * 0.27), COLORS.black) + self._draw_text_centered_fit(self._font_bold, tr("AHEAD"), int(width * 0.34), rl.Vector2(mid_x, y + height * 0.28), COLORS.black, + width * 0.94, height * 0.32, min_size=20) else: - self._draw_text_centered(self._font_bold, tr("SPEED"), label_size, rl.Vector2(mid_x, y + height * 0.20), COLORS.black) - self._draw_text_centered(self._font_bold, tr("LIMIT"), label_size, rl.Vector2(mid_x, y + height * 0.40), COLORS.black) + self._draw_text_centered_fit(self._font_bold, tr("SPEED"), label_size, rl.Vector2(mid_x, y + height * 0.20), COLORS.black, + width * 0.84, height * 0.24, min_size=16) + self._draw_text_centered_fit(self._font_bold, tr("LIMIT"), label_size, rl.Vector2(mid_x, y + height * 0.40), COLORS.black, + width * 0.84, height * 0.24, min_size=16) - speed_font_size = int(width * 0.52) if len(speed_str) >= 3 else int(width * 0.62) - self._draw_text_centered(self._font_bold, speed_str, speed_font_size, rl.Vector2(mid_x, y + height * 0.72), speed_color) + speed_font_size = int(width * 0.60) if len(speed_str) >= 3 else int(width * 0.72) + self._draw_text_centered_fit(self._font_bold, speed_str, speed_font_size, rl.Vector2(mid_x, y + height * 0.72), speed_color, + width * 0.90, height * 0.52, min_size=32) def _draw_text_centered(self, font, text, size, pos_center, color): sz = measure_text_cached(font, text, size) rl.draw_text_ex(font, text, rl.Vector2(pos_center.x - sz.x / 2, pos_center.y - sz.y / 2), size, 0, color) + def _draw_text_centered_fit(self, font, text, size, pos_center, color, max_width: float, max_height: float, min_size: int = 10): + size = self._fit_font_size(font, text, max_width, max_height, size, min_size) + self._draw_text_centered(font, text, size, pos_center, color) + + def _fit_font_size(self, font, text: str, max_width: float, max_height: float, max_size: int | float, min_size: int) -> int: + size = int(max_size) + while size > min_size: + text_size = measure_text_cached(font, text, size) + if text_size.x <= max_width and text_size.y <= max_height: + return size + size -= 2 + return min_size + + def _offset_badge_rect(self, panel_rect: rl.Rectangle, sign_x: float, sign_y: float, sign_width: float, sign_height: float, + badge_size: float, has_upcoming_limit: bool) -> rl.Rectangle: + if ui_state.is_metric: + radius = min(sign_width, sign_height) / 2 + center_x = sign_x + sign_width / 2 + center_y = sign_y + sign_height / 2 + badge_x_ratio = VIENNA_BADGE_UPCOMING_X_RATIO if has_upcoming_limit else VIENNA_BADGE_X_RATIO + badge_center_x = center_x + radius * badge_x_ratio + badge_center_y = center_y + radius * VIENNA_BADGE_Y_RATIO + badge_x = badge_center_x - badge_size / 2 + badge_y = badge_center_y - badge_size / 2 + else: + badge_x = sign_x + sign_width - badge_size * 0.45 + badge_y = sign_y - badge_size * 0.75 + + return rl.Rectangle( + self._clamp( + badge_x, + panel_rect.x + OFFSET_BADGE_PANEL_PADDING, + panel_rect.x + panel_rect.width - badge_size - OFFSET_BADGE_PANEL_PADDING, + ), + self._clamp( + badge_y, + panel_rect.y + OFFSET_BADGE_PANEL_PADDING, + panel_rect.y + panel_rect.height - badge_size - OFFSET_BADGE_PANEL_PADDING, + ), + badge_size, + badge_size, + ) + + @staticmethod + def _clamp(value: float, min_value: float, max_value: float) -> float: + return max(min_value, min(max_value, value)) + def _format_distance(self, distance: float) -> str: if ui_state.is_metric: if distance < 50: diff --git a/selfdrive/ui/sunnypilot/mici/tests/test_scroller_sp.py b/selfdrive/ui/sunnypilot/mici/tests/test_scroller_sp.py new file mode 100644 index 0000000000..770d1a99d7 --- /dev/null +++ b/selfdrive/ui/sunnypilot/mici/tests/test_scroller_sp.py @@ -0,0 +1,83 @@ +import pyray as rl + +from openpilot.system.ui.lib.application import MouseEvent, MousePos, gui_app +from openpilot.system.ui.lib.scroll_panel2 import ScrollState +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets import scroller as scroller_mod +from openpilot.selfdrive.ui.sunnypilot.mici.widgets.scroll_panel_sp import GuiScrollPanel2SP + + +class DummyScrollIndicator: + def update(self, *_) -> None: + pass + + def render(self) -> None: + pass + + +class DummyWidget(Widget): + def __init__(self, rect: rl.Rectangle): + super().__init__() + self.set_rect(rect) + + def _render(self, _) -> None: + pass + + +def _mouse_event(x: float, y: float, *, pressed: bool = False, released: bool = False, + down: bool = True, t: float = 0.0) -> MouseEvent: + return MouseEvent(MousePos(x, y), 0, pressed, released, down, t) + + +def test_vertical_snap_items_are_supported(monkeypatch): + monkeypatch.setattr(scroller_mod, "ScrollIndicator", DummyScrollIndicator) + + scroller = scroller_mod._Scroller([], horizontal=False, snap_items=True, scroll_indicator=False) + scroller.set_rect(rl.Rectangle(0, 0, 100, 100)) + scroller.scroll_panel.set_offset(-60) + + captured_snap_target = None + + def update(_, __, snap_target=None): + nonlocal captured_snap_target + captured_snap_target = snap_target + return scroller.scroll_panel.get_offset() + + monkeypatch.setattr(scroller.scroll_panel, "update", update) + + visible_items = [ + DummyWidget(rl.Rectangle(0, -60, 100, 100)), + DummyWidget(rl.Rectangle(0, 40, 100, 100)), + ] + scroller._get_scroll(visible_items, 200) + + assert captured_snap_target == -100 + + +def test_scroll_panel_sp_rejects_orthogonal_drags(monkeypatch): + panel = GuiScrollPanel2SP(horizontal=True) + bounds = rl.Rectangle(0, 0, 100, 100) + + monkeypatch.setattr(gui_app, "_mouse_events", [_mouse_event(10, 10, pressed=True, t=1.0)]) + panel.update(bounds, 200) + assert panel.state == ScrollState.PRESSED + + monkeypatch.setattr(gui_app, "_mouse_events", [_mouse_event(23, 60, t=1.1)]) + panel.update(bounds, 200) + + assert panel.state == ScrollState.STEADY + assert panel.get_offset() == 0 + + +def test_scroll_panel_sp_can_disable_out_of_bounds_handling(monkeypatch): + panel = GuiScrollPanel2SP(horizontal=False, handle_out_of_bounds=False) + bounds = rl.Rectangle(0, 0, 100, 100) + monkeypatch.setattr(gui_app, "_mouse_events", []) + + panel.set_offset(20) + panel.update(bounds, 200) + assert panel.get_offset() == 0 + + panel.set_offset(-150) + panel.update(bounds, 200) + assert panel.get_offset() == -100 diff --git a/selfdrive/ui/sunnypilot/mici/widgets/scroll_panel_sp.py b/selfdrive/ui/sunnypilot/mici/widgets/scroll_panel_sp.py index 1c97f59e84..38fc742f01 100644 --- a/selfdrive/ui/sunnypilot/mici/widgets/scroll_panel_sp.py +++ b/selfdrive/ui/sunnypilot/mici/widgets/scroll_panel_sp.py @@ -11,24 +11,23 @@ from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2, ScrollState class GuiScrollPanel2SP(GuiScrollPanel2): - """Reject orthogonal-dominant drags so nested scrollers (outer horizontal + - inner vertical) don't both engage on a slightly diagonal swipe. + """Scroll panel behavior for nested Mici pagers.""" - Implemented as a post-super state rollback rather than reimplementing the - PRESSED state machine — keeps stock behaviour authoritative.""" + def __init__(self, horizontal: bool = True, handle_out_of_bounds: bool = True) -> None: + super().__init__(horizontal, handle_out_of_bounds=handle_out_of_bounds) def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, bounds_size: float, content_size: float) -> None: - pre_state = self._state + state_before_update = self._state super()._handle_mouse_event(mouse_event, bounds, bounds_size, content_size) - if self._state == ScrollState.MANUAL_SCROLL and pre_state == ScrollState.PRESSED and \ + if self._state == ScrollState.MANUAL_SCROLL and state_before_update == ScrollState.PRESSED and \ self._initial_click_event is not None: - diff_x = abs(mouse_event.pos.x - self._initial_click_event.pos.x) - diff_y = abs(mouse_event.pos.y - self._initial_click_event.pos.y) - along = diff_x if self._horizontal else diff_y - anti = diff_y if self._horizontal else diff_x - if anti > along: + drag_x = abs(mouse_event.pos.x - self._initial_click_event.pos.x) + drag_y = abs(mouse_event.pos.y - self._initial_click_event.pos.y) + primary_drag = drag_x if self._horizontal else drag_y + cross_drag = drag_y if self._horizontal else drag_x + if cross_drag > primary_drag: self._state = ScrollState.STEADY self._velocity = 0.0 self._velocity_buffer.clear() diff --git a/system/ui/lib/scroll_panel2.py b/system/ui/lib/scroll_panel2.py index 7fae60119c..faf842d20f 100644 --- a/system/ui/lib/scroll_panel2.py +++ b/system/ui/lib/scroll_panel2.py @@ -45,8 +45,9 @@ class ScrollState(Enum): class GuiScrollPanel2: - def __init__(self, horizontal: bool = True) -> None: + def __init__(self, horizontal: bool = True, handle_out_of_bounds: bool = True) -> None: self._horizontal = horizontal + self._handle_out_of_bounds = handle_out_of_bounds self._state = ScrollState.STEADY self._offset: rl.Vector2 = rl.Vector2(0, 0) self._initial_click_event: MouseEvent | None = None @@ -85,6 +86,20 @@ class GuiScrollPanel2: """Returns (max_offset, min_offset) for the given bounds and content size.""" return 0.0, min(0.0, bounds_size - content_size) + def _clamp_offset(self, bounds_size: float, content_size: float) -> None: + if self._handle_out_of_bounds: + return + + max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size) + offset = self.get_offset() + clamped_offset = max(min_offset, min(max_offset, offset)) + if clamped_offset == offset: + return + + self.set_offset(clamped_offset) + if (clamped_offset == max_offset and self._velocity > 0) or (clamped_offset == min_offset and self._velocity < 0): + self._velocity = 0.0 + def _update_state(self, bounds_size: float, content_size: float, snap_target: float | None) -> None: """Runs per render frame, independent of mouse events. Updates auto-scrolling state and velocity.""" max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size) @@ -138,6 +153,8 @@ class GuiScrollPanel2: factor = 1.0 - math.exp(-SNAP_RATE * dt) self.set_offset(self.get_offset() + dist * factor) + self._clamp_offset(bounds_size, content_size) + def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, bounds_size: float, content_size: float) -> None: max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size) diff --git a/system/ui/widgets/scroller.py b/system/ui/widgets/scroller.py index b7b6bf5932..2a70abcd72 100644 --- a/system/ui/widgets/scroller.py +++ b/system/ui/widgets/scroller.py @@ -75,7 +75,6 @@ class _Scroller(Widget): self._items: list[Widget] = [] self._horizontal = horizontal self._snap_items = snap_items - assert not self._snap_items or self._horizontal, "Snapping is only supported for horizontal scrolling" self._spacing = spacing self._pad = pad @@ -191,12 +190,20 @@ class _Scroller(Widget): snap_target: float | None = None if self._snap_items and visible_items and self._scrolling_to[0] is None: # TODO: this doesn't handle two small buttons at the edges well - center_pos = self._rect.x + self._rect.width / 2 - closest_delta_pos = min((((item.rect.x + item.rect.width / 2) - center_pos) for item in visible_items), key=abs) + center_pos = (self._rect.x + self._rect.width / 2) if self._horizontal else (self._rect.y + self._rect.height / 2) + closest_delta_pos = min( + (self._item_center_pos(item) - center_pos for item in visible_items), + key=abs, + ) snap_target = self.scroll_panel.get_offset() - closest_delta_pos return self.scroll_panel.update(self._rect, content_size, snap_target=snap_target) + def _item_center_pos(self, item: Widget) -> float: + if self._horizontal: + return item.rect.x + item.rect.width / 2 + return item.rect.y + item.rect.height / 2 + @property def moving_items(self) -> bool: return len(self._move_animations) > 0 or len(self._move_lift) > 0