Simplify keyboard, robustify animations

This commit is contained in:
Thomas Farstrike
2025-11-15 22:32:21 +01:00
parent dce55f7918
commit b062aa00e3
4 changed files with 418 additions and 95 deletions
+38 -12
View File
@@ -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
+17 -83
View File
@@ -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
@@ -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()
@@ -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()