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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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):
|
||||
|
||||
50
system/ui/tests/test_nav_stack.py
Normal file
50
system/ui/tests/test_nav_stack.py
Normal 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
|
||||
@@ -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 :(
|
||||
|
||||
Reference in New Issue
Block a user