diff --git a/internal_filesystem/lib/mpos/ui/anim.py b/internal_filesystem/lib/mpos/ui/anim.py index abe78f48..521ee9ac 100644 --- a/internal_filesystem/lib/mpos/ui/anim.py +++ b/internal_filesystem/lib/mpos/ui/anim.py @@ -1,5 +1,31 @@ import lvgl as lv + +def safe_widget_access(callback): + """ + Wrapper to safely access a widget, catching LvReferenceError. + + If the widget has been deleted, the callback is silently skipped. + This prevents crashes when animations try to access deleted widgets. + + Args: + callback: Function to call (should access a widget) + + Returns: + None (always, even if callback returns a value) + """ + try: + callback() + except Exception as e: + # Check if it's an LvReferenceError (widget was deleted) + if "LvReferenceError" in str(type(e).__name__) or "Referenced object was deleted" in str(e): + # Widget was deleted - silently ignore + pass + else: + # Some other error - re-raise it + raise + + class WidgetAnimator: # def __init__(self): @@ -27,10 +53,10 @@ class WidgetAnimator: anim.set_values(0, 255) anim.set_duration(duration) anim.set_delay(delay) - anim.set_custom_exec_cb(lambda anim, value: widget.set_style_opa(value, 0)) + anim.set_custom_exec_cb(lambda anim, value: safe_widget_access(lambda: widget.set_style_opa(value, 0))) anim.set_path_cb(lv.anim_t.path_ease_in_out) # Ensure opacity is reset after animation - anim.set_completed_cb(lambda *args: widget.set_style_opa(255, 0)) + anim.set_completed_cb(lambda *args: safe_widget_access(lambda: widget.set_style_opa(255, 0))) elif anim_type == "slide_down": print("doing slide_down") # Create slide-down animation (y from -height to original y) @@ -42,10 +68,10 @@ class WidgetAnimator: anim.set_values(original_y - height, original_y) anim.set_duration(duration) anim.set_delay(delay) - anim.set_custom_exec_cb(lambda anim, value: widget.set_y(value)) + anim.set_custom_exec_cb(lambda anim, value: safe_widget_access(lambda: widget.set_y(value))) anim.set_path_cb(lv.anim_t.path_ease_in_out) # Reset y position after animation - anim.set_completed_cb(lambda *args: widget.set_y(original_y)) + anim.set_completed_cb(lambda *args: safe_widget_access(lambda: widget.set_y(original_y))) elif anim_type == "slide_up": # Create slide-up animation (y from +height to original y) # Seems to cause scroll bars to be added somehow if done to a keyboard at the bottom of the screen... @@ -57,10 +83,10 @@ class WidgetAnimator: anim.set_values(original_y + height, original_y) anim.set_duration(duration) anim.set_delay(delay) - anim.set_custom_exec_cb(lambda anim, value: widget.set_y(value)) + anim.set_custom_exec_cb(lambda anim, value: safe_widget_access(lambda: widget.set_y(value))) anim.set_path_cb(lv.anim_t.path_ease_in_out) # Reset y position after animation - anim.set_completed_cb(lambda *args: widget.set_y(original_y)) + anim.set_completed_cb(lambda *args: safe_widget_access(lambda: widget.set_y(original_y))) # Store and start animation #self.animations[widget] = anim @@ -77,10 +103,10 @@ class WidgetAnimator: anim.set_values(255, 0) anim.set_duration(duration) anim.set_delay(delay) - anim.set_custom_exec_cb(lambda anim, value: widget.set_style_opa(value, 0)) + anim.set_custom_exec_cb(lambda anim, value: safe_widget_access(lambda: widget.set_style_opa(value, 0))) anim.set_path_cb(lv.anim_t.path_ease_in_out) # Set HIDDEN flag after animation - anim.set_completed_cb(lambda *args: WidgetAnimator.hide_complete_cb(widget, hide=hide)) + anim.set_completed_cb(lambda *args: safe_widget_access(lambda: WidgetAnimator.hide_complete_cb(widget, hide=hide))) elif anim_type == "slide_down": # Create slide-down animation (y from original y to +height) # Seems to cause scroll bars to be added somehow if done to a keyboard at the bottom of the screen... @@ -92,10 +118,10 @@ class WidgetAnimator: anim.set_values(original_y, original_y + height) anim.set_duration(duration) anim.set_delay(delay) - anim.set_custom_exec_cb(lambda anim, value: widget.set_y(value)) + anim.set_custom_exec_cb(lambda anim, value: safe_widget_access(lambda: widget.set_y(value))) anim.set_path_cb(lv.anim_t.path_ease_in_out) # Set HIDDEN flag after animation - anim.set_completed_cb(lambda *args: WidgetAnimator.hide_complete_cb(widget, original_y, hide)) + anim.set_completed_cb(lambda *args: safe_widget_access(lambda: WidgetAnimator.hide_complete_cb(widget, original_y, hide))) elif anim_type == "slide_up": print("hide with slide_up") # Create slide-up animation (y from original y to -height) @@ -107,10 +133,10 @@ class WidgetAnimator: anim.set_values(original_y, original_y - height) anim.set_duration(duration) anim.set_delay(delay) - anim.set_custom_exec_cb(lambda anim, value: widget.set_y(value)) + anim.set_custom_exec_cb(lambda anim, value: safe_widget_access(lambda: widget.set_y(value))) anim.set_path_cb(lv.anim_t.path_ease_in_out) # Set HIDDEN flag after animation - anim.set_completed_cb(lambda *args: WidgetAnimator.hide_complete_cb(widget, original_y, hide)) + anim.set_completed_cb(lambda *args: safe_widget_access(lambda: WidgetAnimator.hide_complete_cb(widget, original_y, hide))) # Store and start animation #self.animations[widget] = anim diff --git a/internal_filesystem/lib/mpos/ui/keyboard.py b/internal_filesystem/lib/mpos/ui/keyboard.py index c3a17812..a5df1a20 100644 --- a/internal_filesystem/lib/mpos/ui/keyboard.py +++ b/internal_filesystem/lib/mpos/ui/keyboard.py @@ -179,98 +179,32 @@ class CustomKeyboard: ta.set_text(new_text) # ======================================================================== - # LVGL keyboard-compatible API + # Python magic method for automatic method forwarding # ======================================================================== - def set_textarea(self, textarea): - """Set the textarea that this keyboard should edit.""" - self._keyboard.set_textarea(textarea) + def __getattr__(self, name): + """ + Forward any undefined method/attribute to the underlying LVGL keyboard. - def get_textarea(self): - """Get the currently associated textarea.""" - return self._keyboard.get_textarea() + This allows CustomKeyboard to support ALL lv.keyboard methods automatically + without needing to manually wrap each one. Any method not defined on + CustomKeyboard will be forwarded to self._keyboard. - def set_mode(self, mode): - """Set keyboard mode (use MODE_* constants).""" - self._keyboard.set_mode(mode) - - def align(self, align_type, x_offset=0, y_offset=0): - """Align the keyboard.""" - self._keyboard.align(align_type, x_offset, y_offset) - - def set_style_min_height(self, height, selector): - """Set minimum height.""" - self._keyboard.set_style_min_height(height, selector) - - def set_style_height(self, height, selector): - """Set height.""" - self._keyboard.set_style_height(height, selector) - - def set_style_max_height(self, height, selector): - """Set maximum height.""" - self._keyboard.set_style_max_height(height, selector) - - def set_style_opa(self, opacity, selector): - """Set opacity (required for fade animations).""" - self._keyboard.set_style_opa(opacity, selector) - - def get_x(self): - """Get X position.""" - return self._keyboard.get_x() - - def set_x(self, x): - """Set X position.""" - self._keyboard.set_x(x) - - def get_y(self): - """Get Y position.""" - return self._keyboard.get_y() - - def set_y(self, y): - """Set Y position.""" - self._keyboard.set_y(y) - - def set_pos(self, x, y): - """Set position.""" - self._keyboard.set_pos(x, y) - - def get_height(self): - """Get height.""" - return self._keyboard.get_height() - - def get_width(self): - """Get width.""" - return self._keyboard.get_width() - - def add_flag(self, flag): - """Add object flag (e.g., HIDDEN).""" - self._keyboard.add_flag(flag) - - def remove_flag(self, flag): - """Remove object flag.""" - self._keyboard.remove_flag(flag) - - def has_flag(self, flag): - """Check if object has flag.""" - return self._keyboard.has_flag(flag) - - def add_event_cb(self, callback, event_code, user_data): - """Add event callback.""" - self._keyboard.add_event_cb(callback, event_code, user_data) - - def remove_event_cb(self, callback): - """Remove event callback.""" - self._keyboard.remove_event_cb(callback) - - def send_event(self, event_code, param): - """Send event to keyboard.""" - self._keyboard.send_event(event_code, param) + Examples: + keyboard.set_textarea(ta) # Works + keyboard.align(lv.ALIGN.CENTER) # Works + keyboard.set_style_opa(128, 0) # Works + keyboard.any_lvgl_method() # Works! + """ + # Forward to the underlying keyboard object + return getattr(self._keyboard, name) def get_lvgl_obj(self): """ Get the underlying LVGL keyboard object. - Use this if you need direct access to LVGL methods not wrapped here. + This is now rarely needed since __getattr__ forwards everything automatically. + Kept for backwards compatibility. """ return self._keyboard diff --git a/tests/test_graphical_animation_deleted_widget.py b/tests/test_graphical_animation_deleted_widget.py new file mode 100644 index 00000000..cde1f540 --- /dev/null +++ b/tests/test_graphical_animation_deleted_widget.py @@ -0,0 +1,218 @@ +""" +Test that animations handle deleted widgets gracefully. + +This test reproduces the crash that occurs when: +1. An animation is started on a widget (e.g., keyboard fade-in) +2. The widget is deleted while the animation is running (e.g., user closes app) +3. The animation callback tries to access the deleted widget +4. Result: LvReferenceError crash + +The fix should make animations check if the widget still exists before +trying to access it. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_animation_deleted_widget.py + Device: ./tests/unittest.sh tests/test_graphical_animation_deleted_widget.py ondevice +""" + +import unittest +import lvgl as lv +import mpos.ui.anim +import time +from graphical_test_helper import wait_for_render + + +class TestAnimationDeletedWidget(unittest.TestCase): + """Test that animations don't crash when widget is deleted.""" + + def setUp(self): + """Set up test fixtures.""" + self.screen = lv.obj() + self.screen.set_size(320, 240) + lv.screen_load(self.screen) + print("\n=== Animation Deletion Test Setup ===") + + def tearDown(self): + """Clean up.""" + lv.screen_load(lv.obj()) + wait_for_render(5) + print("=== Test Cleanup Complete ===\n") + + def test_smooth_show_with_deleted_widget(self): + """ + Test that smooth_show doesn't crash if widget is deleted during animation. + + This reproduces the exact scenario: + - User opens keyboard (smooth_show animation starts) + - User presses escape (app closes, deleting all widgets) + - Animation tries to complete on deleted widget + """ + print("Testing smooth_show with deleted widget...") + + # Create a widget + widget = lv.obj(self.screen) + widget.set_size(200, 100) + widget.center() + widget.add_flag(lv.obj.FLAG.HIDDEN) + + # Start fade-in animation (500ms duration) + print("Starting smooth_show animation...") + mpos.ui.anim.smooth_show(widget) + + # Give animation time to start + wait_for_render(2) + + # Delete the widget while animation is running (simulates app close) + print("Deleting widget while animation is running...") + widget.delete() + + # Process LVGL tasks - this should trigger animation callbacks + # If not fixed, this will crash with LvReferenceError + print("Processing LVGL tasks (animation callbacks)...") + try: + for _ in range(100): + lv.task_handler() + time.sleep(0.01) # 1 second total to let animation complete + print("SUCCESS: No crash when accessing deleted widget") + except Exception as e: + if "LvReferenceError" in str(type(e).__name__): + self.fail(f"CRASH: Animation tried to access deleted widget: {e}") + else: + raise + + print("=== smooth_show deletion test PASSED ===") + + def test_smooth_hide_with_deleted_widget(self): + """ + Test that smooth_hide doesn't crash if widget is deleted during animation. + """ + print("Testing smooth_hide with deleted widget...") + + # Create a visible widget + widget = lv.obj(self.screen) + widget.set_size(200, 100) + widget.center() + # Start visible + widget.remove_flag(lv.obj.FLAG.HIDDEN) + + # Start fade-out animation + print("Starting smooth_hide animation...") + mpos.ui.anim.smooth_hide(widget) + + # Give animation time to start + wait_for_render(2) + + # Delete the widget while animation is running + print("Deleting widget while animation is running...") + widget.delete() + + # Process LVGL tasks + print("Processing LVGL tasks (animation callbacks)...") + try: + for _ in range(100): + lv.task_handler() + time.sleep(0.01) + print("SUCCESS: No crash when accessing deleted widget") + except Exception as e: + if "LvReferenceError" in str(type(e).__name__): + self.fail(f"CRASH: Animation tried to access deleted widget: {e}") + else: + raise + + print("=== smooth_hide deletion test PASSED ===") + + def test_keyboard_scenario(self): + """ + Test the exact scenario from QuasiNametag: + 1. Create keyboard with smooth_show + 2. Delete screen (simulating app close with ESC) + 3. Should not crash + """ + print("Testing keyboard deletion scenario...") + + from mpos.ui.keyboard import CustomKeyboard + + # Create textarea and keyboard (like QuasiNametag does) + textarea = lv.textarea(self.screen) + textarea.set_size(280, 40) + textarea.align(lv.ALIGN.TOP_MID, 0, 10) + + keyboard = CustomKeyboard(self.screen) + keyboard.set_textarea(textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + keyboard.add_flag(lv.obj.FLAG.HIDDEN) + + # User clicks textarea - keyboard shows with animation + print("Showing keyboard with animation...") + mpos.ui.anim.smooth_show(keyboard) + + # Give animation time to start + wait_for_render(2) + + # User presses ESC - app closes, screen is deleted + print("Deleting screen (simulating app close)...") + # Create new screen first, then delete old one + new_screen = lv.obj() + lv.screen_load(new_screen) + self.screen.delete() + self.screen = new_screen + + # Process LVGL tasks - animation callbacks should not crash + print("Processing LVGL tasks after deletion...") + try: + for _ in range(100): + lv.task_handler() + time.sleep(0.01) + print("SUCCESS: No crash after deleting screen with animating keyboard") + except Exception as e: + if "LvReferenceError" in str(type(e).__name__): + self.fail(f"CRASH: Keyboard animation tried to access deleted widget: {e}") + else: + raise + + print("=== Keyboard scenario test PASSED ===") + + def test_multiple_animations_deleted(self): + """ + Test that multiple widgets with animations can be deleted safely. + """ + print("Testing multiple animated widgets deletion...") + + widgets = [] + for i in range(5): + w = lv.obj(self.screen) + w.set_size(50, 50) + w.set_pos(i * 60, 50) + w.add_flag(lv.obj.FLAG.HIDDEN) + widgets.append(w) + + # Start animations on all widgets + print("Starting animations on 5 widgets...") + for w in widgets: + mpos.ui.anim.smooth_show(w) + + wait_for_render(2) + + # Delete all widgets while animations are running + print("Deleting all widgets while animations are running...") + for w in widgets: + w.delete() + + # Process tasks + print("Processing LVGL tasks...") + try: + for _ in range(100): + lv.task_handler() + time.sleep(0.01) + print("SUCCESS: No crash with multiple deleted widgets") + except Exception as e: + if "LvReferenceError" in str(type(e).__name__): + self.fail(f"CRASH: Multiple animations crashed on deleted widgets: {e}") + else: + raise + + print("=== Multiple animations test PASSED ===") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_graphical_keyboard_method_forwarding.py b/tests/test_graphical_keyboard_method_forwarding.py new file mode 100644 index 00000000..3b222534 --- /dev/null +++ b/tests/test_graphical_keyboard_method_forwarding.py @@ -0,0 +1,145 @@ +""" +Test that CustomKeyboard forwards all methods to underlying lv.keyboard. + +This demonstrates the __getattr__ magic method works correctly and that +CustomKeyboard supports any LVGL keyboard method without manual wrapping. + +Usage: + Desktop: ./tests/unittest.sh tests/test_keyboard_method_forwarding.py +""" + +import unittest +import lvgl as lv +from mpos.ui.keyboard import CustomKeyboard + + +class TestMethodForwarding(unittest.TestCase): + """Test that arbitrary LVGL methods are forwarded correctly.""" + + def setUp(self): + """Set up test fixtures.""" + self.screen = lv.obj() + self.screen.set_size(320, 240) + lv.screen_load(self.screen) + + def tearDown(self): + """Clean up.""" + lv.screen_load(lv.obj()) + + def test_common_methods_work(self): + """Test commonly used LVGL methods work via __getattr__.""" + print("\nTesting common LVGL methods...") + + keyboard = CustomKeyboard(self.screen) + + # These should all work without explicit wrapper methods: + methods_to_test = [ + ('set_style_opa', (128, 0)), + ('get_x', ()), + ('get_y', ()), + ('get_width', ()), + ('get_height', ()), + ('add_flag', (lv.obj.FLAG.HIDDEN,)), + ('has_flag', (lv.obj.FLAG.HIDDEN,)), + ('remove_flag', (lv.obj.FLAG.HIDDEN,)), + ] + + for method_name, args in methods_to_test: + try: + method = getattr(keyboard, method_name) + result = method(*args) + print(f" ✓ {method_name}{args} -> {result}") + except Exception as e: + self.fail(f"{method_name} failed: {e}") + + print("All common methods work!") + + def test_style_methods_work(self): + """Test various style methods work.""" + print("\nTesting style methods...") + + keyboard = CustomKeyboard(self.screen) + + # All these style methods should work: + keyboard.set_style_min_height(100, 0) + keyboard.set_style_max_height(200, 0) + keyboard.set_style_height(150, 0) + keyboard.set_style_opa(255, 0) + + print("All style methods work!") + + def test_position_methods_work(self): + """Test position methods work.""" + print("\nTesting position methods...") + + keyboard = CustomKeyboard(self.screen) + + # Position methods: + x = keyboard.get_x() + y = keyboard.get_y() + print(f" Initial position: ({x}, {y})") + + keyboard.set_x(50) + keyboard.set_y(100) + keyboard.set_pos(25, 75) + + new_x = keyboard.get_x() + new_y = keyboard.get_y() + print(f" After set_pos(25, 75): ({new_x}, {new_y})") + + print("All position methods work!") + + def test_undocumented_methods_still_work(self): + """ + Test that even undocumented/obscure LVGL methods work. + + The beauty of __getattr__ is that ANY lv.keyboard method works, + even ones we didn't explicitly think about. + """ + print("\nTesting that arbitrary LVGL methods work...") + + keyboard = CustomKeyboard(self.screen) + + # Try some less common methods: + try: + # Get the parent object + parent = keyboard.get_parent() + print(f" ✓ get_parent() -> {parent}") + + # Get style properties + border_width = keyboard.get_style_border_width(lv.PART.MAIN) + print(f" ✓ get_style_border_width() -> {border_width}") + + # These methods exist on lv.obj and should work: + keyboard.set_style_border_width(2, 0) + print(f" ✓ set_style_border_width(2, 0)") + + except Exception as e: + self.fail(f"Arbitrary LVGL method failed: {e}") + + print("Even undocumented methods work via __getattr__!") + + def test_method_forwarding_preserves_behavior(self): + """ + Test that forwarded methods behave identically to native calls. + """ + print("\nTesting that forwarding preserves behavior...") + + keyboard = CustomKeyboard(self.screen) + textarea = lv.textarea(self.screen) + + # Set textarea through CustomKeyboard + keyboard.set_textarea(textarea) + + # Get it back + returned_ta = keyboard.get_textarea() + + # Should be the same object + self.assertEqual(returned_ta, textarea, + "Forwarded methods should preserve object identity") + + print("Method forwarding preserves behavior correctly!") + + +if __name__ == "__main__": + unittest.main()