ui: widgets animate out (#37321)

* stash

* widgets animate out

* Revert "stash"

This reverts commit eac3493509cff6f2c64111d803c7fef21a1aa2dd.

* abstract

* works also

* works also

* support pop_widget

* only animate top

* callback in request pop

* tune it

* fix

* fix

* try this

* Revert "try this"

This reverts commit 191373a1b35917ee3a361afe73b16eeb60d0a20e.

* debug

* debug

* clean up

* simple test

* clean up

* clean up

* clean up

* clean up

* clean up

* clean up

* clkean up

* re sort

* fine

* yes
This commit is contained in:
Shane Smiskol
2026-02-27 21:21:33 -08:00
committed by GitHub
parent 10f3f56801
commit 47ca2c9381
6 changed files with 112 additions and 14 deletions

View File

@@ -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)

View File

@@ -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):

View File

@@ -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]

View File

@@ -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):

View File

@@ -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

View File

@@ -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 :(