diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index 1a0c6bc09..b8958993f 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -93,14 +93,14 @@ class MiciMainLayout(Scroller): self._scroll_to(self._home_layout) if self._onroad_time_delay is not None and rl.get_time() - self._onroad_time_delay >= ONROAD_DELAY: - gui_app.pop_widgets_to(self) + gui_app.request_pop_widgets_to(self) self._scroll_to(self._onroad_layout) self._onroad_time_delay = None # When car leaves standstill, pop nav stack and scroll to onroad CS = ui_state.sm["carState"] if not CS.standstill and self._prev_standstill: - gui_app.pop_widgets_to(self) + gui_app.request_pop_widgets_to(self) self._scroll_to(self._onroad_layout) self._prev_standstill = CS.standstill @@ -112,9 +112,10 @@ class MiciMainLayout(Scroller): if ui_state.started: # Don't pop if at standstill if not ui_state.sm["carState"].standstill: - gui_app.pop_widgets_to(self) + gui_app.request_pop_widgets_to(self) self._scroll_to(self._onroad_layout) else: + # Screen turns off on timeout offroad, so pop immediately without animation gui_app.pop_widgets_to(self) self._scroll_to(self._home_layout) diff --git a/selfdrive/ui/mici/widgets/dialog.py b/selfdrive/ui/mici/widgets/dialog.py index 8e978066c..1191f031b 100644 --- a/selfdrive/ui/mici/widgets/dialog.py +++ b/selfdrive/ui/mici/widgets/dialog.py @@ -82,8 +82,8 @@ class BigConfirmationDialogV2(BigDialogBase): def _on_confirm(self): if self._exit_on_confirm: - gui_app.pop_widget() - if self._confirm_callback: + gui_app.request_pop_widget(self._confirm_callback) + elif self._confirm_callback: self._confirm_callback() def _update_state(self): @@ -128,9 +128,7 @@ class BigInputDialog(BigDialogBase): def confirm_callback_wrapper(): text = self._keyboard.text() - gui_app.pop_widget() - if confirm_callback: - confirm_callback(text) + gui_app.request_pop_widget(lambda: confirm_callback(text) if confirm_callback else None) self._confirm_callback = confirm_callback_wrapper def _update_state(self): diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index 34aed4f6a..ebfa385ea 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -384,17 +384,25 @@ class GuiApplication: self._nav_stack.append(widget) widget.show_event() - def pop_widget(self): + # pop_widget and pop_widgets_to are immediate (no animation). Use request_* variants for animated dismiss. + def pop_widget(self, idx: int | None = None): if len(self._nav_stack) < 2: cloudlog.warning("At least one widget should remain on the stack, ignoring pop!") return - # re-enable previous widget and pop current + if idx is None: + idx = -1 + else: + if idx < 1 or idx >= len(self._nav_stack): + return + + # re-enable widget below, and re-enable popped widget so it's clean if pushed again # TODO: switch to touch_valid - prev_widget = self._nav_stack[-2] + prev_widget = self._nav_stack[idx - 1] prev_widget.set_enabled(True) - widget = self._nav_stack.pop() + widget = self._nav_stack.pop(idx) + widget.set_enabled(True) widget.hide_event() def pop_widgets_to(self, widget): @@ -406,6 +414,33 @@ class GuiApplication: while len(self._nav_stack) > 0 and self._nav_stack[-1] != widget: self.pop_widget() + def request_pop_widget(self, callback: Callable | None = None): + """Request the top widget to close. NavWidgets dismiss (animate then pop); others pop immediately. Callback runs after pop.""" + if len(self._nav_stack) < 2: + cloudlog.warning("At least one widget should remain on the stack, ignoring pop!") + return + top = self._nav_stack[-1] + if hasattr(top, "dismiss"): + top.dismiss(callback) + else: + self.pop_widget() + if callback: + callback() + + def request_pop_widgets_to(self, widget): + """Request to close widgets down to the given widget. Middle widgets are removed via pop_widget logic; only the top animates down.""" + if widget not in self._nav_stack: + cloudlog.warning("Widget not in stack, cannot pop to it!") + return + + if len(self._nav_stack) < 2 or self._nav_stack[-1] == widget: + return + + # Pop second-from-top repeatedly until stack is [target, top]; each goes through re-enable + hide_event + while len(self._nav_stack) > 2 and self._nav_stack[-2] != widget: + self.pop_widget(len(self._nav_stack) - 2) + self.request_pop_widget() + def get_active_widget(self): if len(self._nav_stack) > 0: return self._nav_stack[-1] diff --git a/system/ui/mici_setup.py b/system/ui/mici_setup.py index ca6354443..961cbe753 100755 --- a/system/ui/mici_setup.py +++ b/system/ui/mici_setup.py @@ -534,7 +534,7 @@ class Setup(Widget): def _nav_stack_tick(self): has_internet = self._network_monitor.network_connected.is_set() if has_internet and not self._prev_has_internet: - gui_app.pop_widgets_to(self) + gui_app.request_pop_widgets_to(self) self._prev_has_internet = has_internet def _update_state(self): diff --git a/system/ui/tests/test_nav_stack.py b/system/ui/tests/test_nav_stack.py new file mode 100644 index 000000000..eae5d78c1 --- /dev/null +++ b/system/ui/tests/test_nav_stack.py @@ -0,0 +1,50 @@ +import pytest +from openpilot.system.ui.lib.application import gui_app + + +class Widget: + def __init__(self): + self.enabled, self.shown, self.hidden = True, False, False + + def set_enabled(self, e): self.enabled = e + def show_event(self): self.shown = True + def hide_event(self): self.hidden = True + + +@pytest.fixture(autouse=True) +def clean_stack(): + gui_app._nav_stack = [] + yield + gui_app._nav_stack = [] + + +def test_push(): + a, b = Widget(), Widget() + gui_app.push_widget(a) + gui_app.push_widget(b) + assert not a.enabled and not a.hidden + assert b.enabled and b.shown + + +def test_pop_re_enables(): + widgets = [Widget() for _ in range(4)] + for w in widgets: + gui_app.push_widget(w) + assert all(not w.enabled for w in widgets[:-1]) + gui_app.pop_widget() + assert widgets[-2].enabled + + +@pytest.mark.parametrize("pop_fn", [gui_app.pop_widgets_to, gui_app.request_pop_widgets_to]) +def test_pop_widgets_to(pop_fn): + widgets = [Widget() for _ in range(4)] + for w in widgets: + gui_app.push_widget(w) + + root = widgets[0] + pop_fn(root) + + assert gui_app._nav_stack == [root] + assert root.enabled and not root.hidden + for w in widgets[1:]: + assert w.enabled and w.hidden and w.shown diff --git a/system/ui/widgets/nav_widget.py b/system/ui/widgets/nav_widget.py index 02afc911b..ad8d5d313 100644 --- a/system/ui/widgets/nav_widget.py +++ b/system/ui/widgets/nav_widget.py @@ -17,6 +17,7 @@ NAV_BAR_HEIGHT = 8 DISMISS_PUSH_OFFSET = 50 + NAV_BAR_MARGIN + NAV_BAR_HEIGHT # px extra to push down when dismissing DISMISS_TIME_SECONDS = 2.0 +DISMISS_ANIMATION_RC = 0.2 # time constant for non-user triggered dismiss animation class NavBar(Widget): @@ -62,6 +63,7 @@ class NavWidget(Widget, abc.ABC): self._pos_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps, bounce=1) self._playing_dismiss_animation = False + self._dismiss_callback: Callable | None = None self._trigger_animate_in = False self._nav_bar_show_time = 0.0 self._back_enabled: bool | Callable[[], bool] = True @@ -83,7 +85,8 @@ class NavWidget(Widget, abc.ABC): # FIXME: disabling this widget on new push_widget still causes this widget to track mouse events without mouse down super()._handle_mouse_event(mouse_event) - if not self.back_enabled: + # Don't let touch events change filter state during dismiss animation + if not self.back_enabled or self._playing_dismiss_animation: self._back_button_start_pos = None self._swiping_away = False self._can_swipe_away = True @@ -170,6 +173,10 @@ class NavWidget(Widget, abc.ABC): if self._back_callback is not None: self._back_callback() + if self._dismiss_callback is not None: + self._dismiss_callback() + self._dismiss_callback = None + self._playing_dismiss_animation = False self._back_button_start_pos = None self._swiping_away = False @@ -205,6 +212,13 @@ class NavWidget(Widget, abc.ABC): return ret + def dismiss(self, callback: Callable | None = None): + """Programmatically trigger the dismiss animation. Calls pop_widget when done, then callback.""" + if not self._playing_dismiss_animation: + self._pos_filter.update_alpha(DISMISS_ANIMATION_RC) + self._playing_dismiss_animation = True + self._dismiss_callback = callback + def show_event(self): super().show_event() # FIXME: we don't know the height of the rect at first show_event since it's before the first render :(