From dce55f7918cbe7a2990276fc2f7138649c9753cc Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sat, 15 Nov 2025 22:06:12 +0100 Subject: [PATCH] Add new and improved keyboard --- internal_filesystem/lib/mpos/ui/keyboard.py | 304 +++++++++++++++++ tests/test_graphical_custom_keyboard.py | 320 ++++++++++++++++++ tests/test_graphical_custom_keyboard_basic.py | 192 +++++++++++ tests/test_graphical_keyboard_animation.py | 189 +++++++++++ 4 files changed, 1005 insertions(+) create mode 100644 internal_filesystem/lib/mpos/ui/keyboard.py create mode 100644 tests/test_graphical_custom_keyboard.py create mode 100644 tests/test_graphical_custom_keyboard_basic.py create mode 100644 tests/test_graphical_keyboard_animation.py diff --git a/internal_filesystem/lib/mpos/ui/keyboard.py b/internal_filesystem/lib/mpos/ui/keyboard.py new file mode 100644 index 00000000..c3a17812 --- /dev/null +++ b/internal_filesystem/lib/mpos/ui/keyboard.py @@ -0,0 +1,304 @@ +""" +Custom keyboard for MicroPythonOS. + +This module provides an enhanced on-screen keyboard with better layout, +more characters (including emoticons), and improved usability compared +to the default LVGL keyboard. + +Usage: + from mpos.ui.keyboard import CustomKeyboard + + # Create keyboard + keyboard = CustomKeyboard(parent_obj) + keyboard.set_textarea(my_textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + + # Or use factory function for drop-in replacement + from mpos.ui.keyboard import create_keyboard + keyboard = create_keyboard(parent_obj, custom=True) +""" + +import lvgl as lv +import mpos.ui.theme + + +class CustomKeyboard: + """ + Enhanced keyboard widget with multiple layouts and emoticons. + + Features: + - Lowercase and uppercase letter modes + - Numbers and special characters + - Additional special characters with emoticons + - Automatic mode switching + - Compatible with LVGL keyboard API + """ + + # Keyboard layout labels + LABEL_NUMBERS_SPECIALS = "?123" + LABEL_SPECIALS = "=\<" + LABEL_LETTERS = "abc" + LABEL_SPACE = " " + + # Keyboard modes (using LVGL's USER modes) + MODE_LOWERCASE = lv.keyboard.MODE.USER_1 + MODE_UPPERCASE = lv.keyboard.MODE.USER_2 + MODE_NUMBERS = lv.keyboard.MODE.USER_3 + MODE_SPECIALS = lv.keyboard.MODE.USER_4 + + def __init__(self, parent): + """ + Create a custom keyboard. + + Args: + parent: Parent LVGL object to attach keyboard to + """ + # Create underlying LVGL keyboard widget + self._keyboard = lv.keyboard(parent) + + # Configure layouts + self._setup_layouts() + + # Set default mode to lowercase + self._keyboard.set_mode(self.MODE_LOWERCASE) + + # Add event handler for custom behavior + self._keyboard.add_event_cb(self._handle_events, lv.EVENT.VALUE_CHANGED, None) + + # Apply theme fix for light mode visibility + mpos.ui.theme.fix_keyboard_button_style(self._keyboard) + + # Set reasonable default height + self._keyboard.set_style_min_height(145, 0) + + def _setup_layouts(self): + """Configure all keyboard layout modes.""" + + # Lowercase letters + lowercase_map = [ + "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "\n", + "a", "s", "d", "f", "g", "h", "j", "k", "l", "\n", + lv.SYMBOL.UP, "z", "x", "c", "v", "b", "n", "m", lv.SYMBOL.BACKSPACE, "\n", + self.LABEL_NUMBERS_SPECIALS, ",", self.LABEL_SPACE, ".", lv.SYMBOL.NEW_LINE, None + ] + lowercase_ctrl = [10] * len(lowercase_map) + self._keyboard.set_map(self.MODE_LOWERCASE, lowercase_map, lowercase_ctrl) + + # Uppercase letters + uppercase_map = [ + "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "\n", + "A", "S", "D", "F", "G", "H", "J", "K", "L", "\n", + lv.SYMBOL.DOWN, "Z", "X", "C", "V", "B", "N", "M", lv.SYMBOL.BACKSPACE, "\n", + self.LABEL_NUMBERS_SPECIALS, ",", self.LABEL_SPACE, ".", lv.SYMBOL.NEW_LINE, None + ] + uppercase_ctrl = [10] * len(uppercase_map) + self._keyboard.set_map(self.MODE_UPPERCASE, uppercase_map, uppercase_ctrl) + + # Numbers and common special characters + numbers_map = [ + "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "\n", + "@", "#", "$", "_", "&", "-", "+", "(", ")", "/", "\n", + self.LABEL_SPECIALS, "*", "\"", "'", ":", ";", "!", "?", lv.SYMBOL.BACKSPACE, "\n", + self.LABEL_LETTERS, ",", self.LABEL_SPACE, ".", lv.SYMBOL.NEW_LINE, None + ] + numbers_ctrl = [10] * len(numbers_map) + self._keyboard.set_map(self.MODE_NUMBERS, numbers_map, numbers_ctrl) + + # Additional special characters with emoticons + specials_map = [ + "~", "`", "|", "•", ":-)", ";-)", ":-D", "\n", + ":-(" , ":'-(", "^", "°", "=", "{", "}", "\\", "\n", + self.LABEL_NUMBERS_SPECIALS, ":-o", ":-P", "[", "]", lv.SYMBOL.BACKSPACE, "\n", + self.LABEL_LETTERS, "<", self.LABEL_SPACE, ">", lv.SYMBOL.NEW_LINE, None + ] + specials_ctrl = [10] * len(specials_map) + self._keyboard.set_map(self.MODE_SPECIALS, specials_map, specials_ctrl) + + def _handle_events(self, event): + """ + Handle keyboard button presses. + + Args: + event: LVGL event object + """ + # Get the pressed button and its text + button = self._keyboard.get_selected_button() + text = self._keyboard.get_button_text(button) + + # Get current textarea content + ta = self._keyboard.get_textarea() + if not ta: + return + + current_text = ta.get_text() + new_text = current_text + + # Handle special keys + if text == lv.SYMBOL.BACKSPACE: + # Delete last character + new_text = current_text[:-1] + + elif text == lv.SYMBOL.UP: + # Switch to uppercase + self._keyboard.set_mode(self.MODE_UPPERCASE) + return # Don't modify text + + elif text == lv.SYMBOL.DOWN or text == self.LABEL_LETTERS: + # Switch to lowercase + self._keyboard.set_mode(self.MODE_LOWERCASE) + return # Don't modify text + + elif text == self.LABEL_NUMBERS_SPECIALS: + # Switch to numbers/specials + self._keyboard.set_mode(self.MODE_NUMBERS) + return # Don't modify text + + elif text == self.LABEL_SPECIALS: + # Switch to additional specials + self._keyboard.set_mode(self.MODE_SPECIALS) + return # Don't modify text + + elif text == self.LABEL_SPACE: + # Space bar + new_text = current_text + " " + + elif text == lv.SYMBOL.NEW_LINE: + # Handle newline (only for multi-line textareas) + if ta.get_one_line(): + # For single-line, trigger READY event + self._keyboard.send_event(lv.EVENT.READY, None) + return + else: + new_text = current_text + "\n" + + else: + # Regular character + new_text = current_text + text + + # Update textarea + ta.set_text(new_text) + + # ======================================================================== + # LVGL keyboard-compatible API + # ======================================================================== + + def set_textarea(self, textarea): + """Set the textarea that this keyboard should edit.""" + self._keyboard.set_textarea(textarea) + + def get_textarea(self): + """Get the currently associated textarea.""" + return self._keyboard.get_textarea() + + 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) + + def get_lvgl_obj(self): + """ + Get the underlying LVGL keyboard object. + + Use this if you need direct access to LVGL methods not wrapped here. + """ + return self._keyboard + + +def create_keyboard(parent, custom=False): + """ + Factory function to create a keyboard. + + This provides a simple way to switch between standard LVGL keyboard + and custom keyboard. + + Args: + parent: Parent LVGL object + custom: If True, create CustomKeyboard; if False, create standard lv.keyboard + + Returns: + CustomKeyboard instance or lv.keyboard instance + + Example: + # Use custom keyboard + keyboard = create_keyboard(screen, custom=True) + + # Use standard LVGL keyboard + keyboard = create_keyboard(screen, custom=False) + """ + if custom: + return CustomKeyboard(parent) + else: + keyboard = lv.keyboard(parent) + mpos.ui.theme.fix_keyboard_button_style(keyboard) + return keyboard diff --git a/tests/test_graphical_custom_keyboard.py b/tests/test_graphical_custom_keyboard.py new file mode 100644 index 00000000..cff5e166 --- /dev/null +++ b/tests/test_graphical_custom_keyboard.py @@ -0,0 +1,320 @@ +""" +Graphical tests for CustomKeyboard. + +Tests keyboard visual appearance, text input via simulated button presses, +and mode switching. Captures screenshots for regression testing. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_custom_keyboard.py + Device: ./tests/unittest.sh tests/test_graphical_custom_keyboard.py ondevice +""" + +import unittest +import lvgl as lv +import sys +import os +from mpos.ui.keyboard import CustomKeyboard, create_keyboard +from graphical_test_helper import ( + wait_for_render, + capture_screenshot, +) + + +class TestGraphicalCustomKeyboard(unittest.TestCase): + """Test suite for CustomKeyboard graphical verification.""" + + def setUp(self): + """Set up test fixtures before each test method.""" + # Determine screenshot directory + if sys.platform == "esp32": + self.screenshot_dir = "tests/screenshots" + else: + self.screenshot_dir = "../tests/screenshots" + + # Ensure screenshots directory exists + try: + os.mkdir(self.screenshot_dir) + except OSError: + pass # Directory already exists + + print(f"\n=== Graphical Keyboard Test Setup ===") + print(f"Platform: {sys.platform}") + + def tearDown(self): + """Clean up after each test method.""" + lv.screen_load(lv.obj()) + wait_for_render(5) + print("=== Test Cleanup Complete ===\n") + + def _create_test_keyboard_scene(self): + """ + Create a test scene with textarea and keyboard. + + Returns: + tuple: (screen, keyboard, textarea) + """ + # Create screen + screen = lv.obj() + screen.set_size(320, 240) + + # Create textarea + textarea = lv.textarea(screen) + textarea.set_size(280, 40) + textarea.align(lv.ALIGN.TOP_MID, 0, 10) + textarea.set_placeholder_text("Type here...") + textarea.set_one_line(True) + + # Create custom keyboard + keyboard = CustomKeyboard(screen) + keyboard.set_textarea(textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + + # Load and render + lv.screen_load(screen) + wait_for_render(iterations=20) + + return screen, keyboard, textarea + + def _simulate_button_press(self, keyboard, button_index): + """ + Simulate pressing a keyboard button. + + Args: + keyboard: CustomKeyboard instance + button_index: Index of button to press + + Returns: + str: Text of the pressed button + """ + lvgl_keyboard = keyboard.get_lvgl_obj() + + # Get button text before pressing + button_text = lvgl_keyboard.get_button_text(button_index) + + # Simulate button press by setting it as selected and sending event + # Note: This is a bit of a hack since we can't directly click in tests + # We'll trigger the VALUE_CHANGED event which is what happens on click + + # The keyboard has an internal handler that responds to VALUE_CHANGED + # We need to manually trigger it + lvgl_keyboard.send_event(lv.EVENT.VALUE_CHANGED, None) + + wait_for_render(5) + + return button_text + + def test_keyboard_lowercase_appearance(self): + """ + Test keyboard appearance in lowercase mode. + + Verifies that the keyboard renders correctly and captures screenshot. + """ + print("\n=== Testing lowercase keyboard appearance ===") + + screen, keyboard, textarea = self._create_test_keyboard_scene() + + # Ensure lowercase mode + keyboard.set_mode(CustomKeyboard.MODE_LOWERCASE) + wait_for_render(10) + + # Capture screenshot + screenshot_path = f"{self.screenshot_dir}/custom_keyboard_lowercase.raw" + print(f"Capturing screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # Verify screenshot was created + stat = os.stat(screenshot_path) + self.assertTrue(stat[6] > 0, "Screenshot file is empty") + print(f"Screenshot captured: {stat[6]} bytes") + + print("=== Lowercase appearance test PASSED ===") + + def test_keyboard_uppercase_appearance(self): + """Test keyboard appearance in uppercase mode.""" + print("\n=== Testing uppercase keyboard appearance ===") + + screen, keyboard, textarea = self._create_test_keyboard_scene() + + # Switch to uppercase mode + keyboard.set_mode(CustomKeyboard.MODE_UPPERCASE) + wait_for_render(10) + + # Capture screenshot + screenshot_path = f"{self.screenshot_dir}/custom_keyboard_uppercase.raw" + print(f"Capturing screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # Verify screenshot was created + stat = os.stat(screenshot_path) + self.assertTrue(stat[6] > 0, "Screenshot file is empty") + print(f"Screenshot captured: {stat[6]} bytes") + + print("=== Uppercase appearance test PASSED ===") + + def test_keyboard_numbers_appearance(self): + """Test keyboard appearance in numbers/specials mode.""" + print("\n=== Testing numbers keyboard appearance ===") + + screen, keyboard, textarea = self._create_test_keyboard_scene() + + # Switch to numbers mode + keyboard.set_mode(CustomKeyboard.MODE_NUMBERS) + wait_for_render(10) + + # Capture screenshot + screenshot_path = f"{self.screenshot_dir}/custom_keyboard_numbers.raw" + print(f"Capturing screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # Verify screenshot was created + stat = os.stat(screenshot_path) + self.assertTrue(stat[6] > 0, "Screenshot file is empty") + print(f"Screenshot captured: {stat[6]} bytes") + + print("=== Numbers appearance test PASSED ===") + + def test_keyboard_specials_appearance(self): + """Test keyboard appearance in additional specials mode.""" + print("\n=== Testing specials keyboard appearance ===") + + screen, keyboard, textarea = self._create_test_keyboard_scene() + + # Switch to specials mode + keyboard.set_mode(CustomKeyboard.MODE_SPECIALS) + wait_for_render(10) + + # Capture screenshot + screenshot_path = f"{self.screenshot_dir}/custom_keyboard_specials.raw" + print(f"Capturing screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # Verify screenshot was created + stat = os.stat(screenshot_path) + self.assertTrue(stat[6] > 0, "Screenshot file is empty") + print(f"Screenshot captured: {stat[6]} bytes") + + print("=== Specials appearance test PASSED ===") + + def test_keyboard_visibility_light_mode(self): + """ + Test that custom keyboard buttons are visible in light mode. + + This verifies that the theme fix is applied. + """ + print("\n=== Testing keyboard visibility in light mode ===") + + # Set light mode (should already be default) + import mpos.config + import mpos.ui.theme + prefs = mpos.config.SharedPreferences("theme_settings") + editor = prefs.edit() + editor.put_string("theme_light_dark", "light") + editor.commit() + mpos.ui.theme.set_theme(prefs) + wait_for_render(10) + + # Create keyboard + screen, keyboard, textarea = self._create_test_keyboard_scene() + + # Get button background color + lvgl_keyboard = keyboard.get_lvgl_obj() + bg_color = lvgl_keyboard.get_style_bg_color(lv.PART.ITEMS) + + # Extract RGB (similar to keyboard styling test) + try: + color_dict = { + 'r': bg_color.red() if hasattr(bg_color, 'red') else 0, + 'g': bg_color.green() if hasattr(bg_color, 'green') else 0, + 'b': bg_color.blue() if hasattr(bg_color, 'blue') else 0, + } + except: + try: + color_int = bg_color.to_int() if hasattr(bg_color, 'to_int') else 0 + color_dict = { + 'r': (color_int >> 16) & 0xFF, + 'g': (color_int >> 8) & 0xFF, + 'b': color_int & 0xFF, + } + except: + color_dict = {'r': 0, 'g': 0, 'b': 0} + + print(f"Button background: RGB({color_dict['r']}, {color_dict['g']}, {color_dict['b']})") + + # Verify buttons are NOT pure white (which would be invisible) + if 'r' in color_dict: + is_white = (color_dict['r'] >= 250 and + color_dict['g'] >= 250 and + color_dict['b'] >= 250) + + self.assertFalse( + is_white, + f"Custom keyboard buttons are pure white in light mode (invisible)!" + ) + + print("=== Visibility test PASSED ===") + + def test_keyboard_with_standard_comparison(self): + """ + Test custom keyboard alongside standard keyboard. + + Creates both for visual comparison. + """ + print("\n=== Testing custom vs standard keyboard ===") + + # Create screen with two textareas + screen = lv.obj() + screen.set_size(320, 240) + + # Top textarea with standard keyboard + ta_standard = lv.textarea(screen) + ta_standard.set_size(280, 30) + ta_standard.set_pos(20, 5) + ta_standard.set_placeholder_text("Standard") + ta_standard.set_one_line(True) + + # Create standard keyboard (hidden initially) + keyboard_standard = create_keyboard(screen, custom=False) + keyboard_standard.set_textarea(ta_standard) + keyboard_standard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + keyboard_standard.set_style_min_height(145, 0) + + # Load and render + lv.screen_load(screen) + wait_for_render(20) + + # Capture standard keyboard + screenshot_path = f"{self.screenshot_dir}/keyboard_standard_comparison.raw" + print(f"Capturing standard keyboard: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # Clean up + lv.screen_load(lv.obj()) + wait_for_render(5) + + # Now create custom keyboard + screen2 = lv.obj() + screen2.set_size(320, 240) + + ta_custom = lv.textarea(screen2) + ta_custom.set_size(280, 30) + ta_custom.set_pos(20, 5) + ta_custom.set_placeholder_text("Custom") + ta_custom.set_one_line(True) + + keyboard_custom = create_keyboard(screen2, custom=True) + keyboard_custom.set_textarea(ta_custom) + keyboard_custom.align(lv.ALIGN.BOTTOM_MID, 0, 0) + + lv.screen_load(screen2) + wait_for_render(20) + + # Capture custom keyboard + screenshot_path = f"{self.screenshot_dir}/keyboard_custom_comparison.raw" + print(f"Capturing custom keyboard: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + print("=== Comparison test PASSED ===") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_graphical_custom_keyboard_basic.py b/tests/test_graphical_custom_keyboard_basic.py new file mode 100644 index 00000000..be67a9c5 --- /dev/null +++ b/tests/test_graphical_custom_keyboard_basic.py @@ -0,0 +1,192 @@ +""" +Functional tests for CustomKeyboard. + +Tests keyboard creation, mode switching, text input, and API compatibility. + +Usage: + Desktop: ./tests/unittest.sh tests/test_custom_keyboard.py + Device: ./tests/unittest.sh tests/test_custom_keyboard.py ondevice +""" + +import unittest +import lvgl as lv +from mpos.ui.keyboard import CustomKeyboard, create_keyboard + + +class TestCustomKeyboard(unittest.TestCase): + """Test suite for CustomKeyboard functionality.""" + + def setUp(self): + """Set up test fixtures before each test method.""" + # Create a test screen + self.screen = lv.obj() + self.screen.set_size(320, 240) + + # Create a textarea for testing + self.textarea = lv.textarea(self.screen) + self.textarea.set_size(280, 40) + self.textarea.align(lv.ALIGN.TOP_MID, 0, 10) + self.textarea.set_one_line(True) + + print(f"\n=== Test Setup Complete ===") + + def tearDown(self): + """Clean up after each test method.""" + # Clean up objects + lv.screen_load(lv.obj()) + print("=== Test Cleanup Complete ===\n") + + def test_keyboard_creation(self): + """Test that CustomKeyboard can be created.""" + print("Testing keyboard creation...") + + keyboard = CustomKeyboard(self.screen) + + # Verify keyboard exists + self.assertIsNotNone(keyboard) + self.assertIsNotNone(keyboard.get_lvgl_obj()) + + print("Keyboard created successfully") + + def test_keyboard_factory_custom(self): + """Test factory function creates custom keyboard.""" + print("Testing factory function with custom=True...") + + keyboard = create_keyboard(self.screen, custom=True) + + # Verify it's a CustomKeyboard instance + self.assertIsInstance(keyboard, CustomKeyboard) + + print("Factory created CustomKeyboard successfully") + + def test_keyboard_factory_standard(self): + """Test factory function creates standard keyboard.""" + print("Testing factory function with custom=False...") + + keyboard = create_keyboard(self.screen, custom=False) + + # Verify it's an LVGL keyboard (not CustomKeyboard) + self.assertFalse(isinstance(keyboard, CustomKeyboard), + "Factory with custom=False should not create CustomKeyboard") + # It should be an lv.keyboard instance + self.assertEqual(type(keyboard).__name__, 'keyboard') + + print("Factory created standard keyboard successfully") + + def test_set_textarea(self): + """Test setting textarea association.""" + print("Testing set_textarea...") + + keyboard = CustomKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + + # Verify textarea is associated + associated_ta = keyboard.get_textarea() + self.assertEqual(associated_ta, self.textarea) + + print("Textarea association successful") + + def test_mode_switching(self): + """Test keyboard mode switching.""" + print("Testing mode switching...") + + keyboard = CustomKeyboard(self.screen) + + # Test setting different modes + keyboard.set_mode(CustomKeyboard.MODE_LOWERCASE) + keyboard.set_mode(CustomKeyboard.MODE_UPPERCASE) + keyboard.set_mode(CustomKeyboard.MODE_NUMBERS) + keyboard.set_mode(CustomKeyboard.MODE_SPECIALS) + + print("Mode switching successful") + + def test_alignment(self): + """Test keyboard alignment.""" + print("Testing alignment...") + + keyboard = CustomKeyboard(self.screen) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + + print("Alignment successful") + + def test_height_settings(self): + """Test height configuration.""" + print("Testing height settings...") + + keyboard = CustomKeyboard(self.screen) + keyboard.set_style_min_height(160, 0) + keyboard.set_style_height(160, 0) + + print("Height settings successful") + + def test_flags(self): + """Test object flags (show/hide).""" + print("Testing flags...") + + keyboard = CustomKeyboard(self.screen) + + # Test hiding + keyboard.add_flag(lv.obj.FLAG.HIDDEN) + self.assertTrue(keyboard.has_flag(lv.obj.FLAG.HIDDEN)) + + # Test showing + keyboard.remove_flag(lv.obj.FLAG.HIDDEN) + self.assertFalse(keyboard.has_flag(lv.obj.FLAG.HIDDEN)) + + print("Flag operations successful") + + def test_event_callback(self): + """Test adding event callbacks.""" + print("Testing event callbacks...") + + keyboard = CustomKeyboard(self.screen) + callback_called = [False] + + def test_callback(event): + callback_called[0] = True + + # Add callback + keyboard.add_event_cb(test_callback, lv.EVENT.READY, None) + + # Send READY event + keyboard.send_event(lv.EVENT.READY, None) + + # Verify callback was called + self.assertTrue(callback_called[0], "Callback was not called") + + print("Event callback successful") + + def test_api_compatibility(self): + """Test that CustomKeyboard has same API as lv.keyboard.""" + print("Testing API compatibility...") + + keyboard = CustomKeyboard(self.screen) + + # Check that all essential methods exist + essential_methods = [ + 'set_textarea', + 'get_textarea', + 'set_mode', + 'align', + 'add_flag', + 'remove_flag', + 'has_flag', + 'add_event_cb', + 'send_event', + ] + + for method_name in essential_methods: + self.assertTrue( + hasattr(keyboard, method_name), + f"CustomKeyboard missing method: {method_name}" + ) + self.assertTrue( + callable(getattr(keyboard, method_name)), + f"CustomKeyboard.{method_name} is not callable" + ) + + print("API compatibility verified") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_graphical_keyboard_animation.py b/tests/test_graphical_keyboard_animation.py new file mode 100644 index 00000000..0a81770f --- /dev/null +++ b/tests/test_graphical_keyboard_animation.py @@ -0,0 +1,189 @@ +""" +Test CustomKeyboard animation support (show/hide with mpos.ui.anim). + +This test reproduces the bug where CustomKeyboard is missing methods +required by mpos.ui.anim.smooth_show() and smooth_hide(). + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_keyboard_animation.py + Device: ./tests/unittest.sh tests/test_graphical_keyboard_animation.py ondevice +""" + +import unittest +import lvgl as lv +import mpos.ui.anim +from mpos.ui.keyboard import CustomKeyboard + + +class TestKeyboardAnimation(unittest.TestCase): + """Test CustomKeyboard compatibility with animation system.""" + + def setUp(self): + """Set up test fixtures.""" + # Create a test screen + self.screen = lv.obj() + self.screen.set_size(320, 240) + lv.screen_load(self.screen) + + # Create textarea + self.textarea = lv.textarea(self.screen) + self.textarea.set_size(280, 40) + self.textarea.align(lv.ALIGN.TOP_MID, 0, 10) + self.textarea.set_one_line(True) + + print("\n=== Animation Test Setup Complete ===") + + def tearDown(self): + """Clean up after test.""" + lv.screen_load(lv.obj()) + print("=== Test Cleanup Complete ===\n") + + def test_keyboard_has_set_style_opa(self): + """ + Test that CustomKeyboard has set_style_opa method. + + This method is required by mpos.ui.anim for fade animations. + """ + print("Testing that CustomKeyboard has set_style_opa...") + + keyboard = CustomKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + keyboard.add_flag(lv.obj.FLAG.HIDDEN) + + # Verify method exists + self.assertTrue( + hasattr(keyboard, 'set_style_opa'), + "CustomKeyboard missing set_style_opa method" + ) + self.assertTrue( + callable(getattr(keyboard, 'set_style_opa')), + "CustomKeyboard.set_style_opa is not callable" + ) + + # Try calling it (should not raise AttributeError) + try: + keyboard.set_style_opa(128, 0) + print("set_style_opa called successfully") + except AttributeError as e: + self.fail(f"set_style_opa raised AttributeError: {e}") + + print("=== set_style_opa test PASSED ===") + + def test_keyboard_smooth_show(self): + """ + Test that CustomKeyboard can be shown with smooth_show animation. + + This reproduces the actual user interaction in QuasiNametag. + """ + print("Testing smooth_show animation...") + + keyboard = CustomKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + keyboard.add_flag(lv.obj.FLAG.HIDDEN) + + # This should work without raising AttributeError + try: + mpos.ui.anim.smooth_show(keyboard) + print("smooth_show called successfully") + except AttributeError as e: + self.fail(f"smooth_show raised AttributeError: {e}\n" + "This is the bug - CustomKeyboard missing animation methods") + + # Verify keyboard is no longer hidden + self.assertFalse( + keyboard.has_flag(lv.obj.FLAG.HIDDEN), + "Keyboard should not be hidden after smooth_show" + ) + + print("=== smooth_show test PASSED ===") + + def test_keyboard_smooth_hide(self): + """ + Test that CustomKeyboard can be hidden with smooth_hide animation. + + This reproduces the hide behavior in QuasiNametag. + """ + print("Testing smooth_hide animation...") + + keyboard = CustomKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + # Start visible + keyboard.remove_flag(lv.obj.FLAG.HIDDEN) + + # This should work without raising AttributeError + try: + mpos.ui.anim.smooth_hide(keyboard) + print("smooth_hide called successfully") + except AttributeError as e: + self.fail(f"smooth_hide raised AttributeError: {e}\n" + "This is the bug - CustomKeyboard missing animation methods") + + print("=== smooth_hide test PASSED ===") + + def test_keyboard_show_hide_cycle(self): + """ + Test full show/hide animation cycle. + + This mimics the actual user flow: + 1. Click textarea -> show keyboard + 2. Press Enter/Cancel -> hide keyboard + """ + print("Testing full show/hide cycle...") + + keyboard = CustomKeyboard(self.screen) + keyboard.set_textarea(self.textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + keyboard.add_flag(lv.obj.FLAG.HIDDEN) + + # Initial state: hidden + self.assertTrue(keyboard.has_flag(lv.obj.FLAG.HIDDEN)) + + # Show keyboard (simulates textarea click) + try: + mpos.ui.anim.smooth_show(keyboard) + except AttributeError as e: + self.fail(f"Failed during smooth_show: {e}") + + # Should be visible now + self.assertFalse(keyboard.has_flag(lv.obj.FLAG.HIDDEN)) + + # Hide keyboard (simulates pressing Enter) + try: + mpos.ui.anim.smooth_hide(keyboard) + except AttributeError as e: + self.fail(f"Failed during smooth_hide: {e}") + + print("=== Full cycle test PASSED ===") + + def test_keyboard_has_get_y_and_set_y(self): + """ + Test that CustomKeyboard has get_y and set_y methods. + + These are required for slide animations (though not currently used). + """ + print("Testing get_y and set_y methods...") + + keyboard = CustomKeyboard(self.screen) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + + # Verify methods exist + self.assertTrue(hasattr(keyboard, 'get_y'), "Missing get_y method") + self.assertTrue(hasattr(keyboard, 'set_y'), "Missing set_y method") + + # Try using them + try: + y = keyboard.get_y() + keyboard.set_y(y + 10) + new_y = keyboard.get_y() + print(f"Position test: {y} -> {new_y}") + except AttributeError as e: + self.fail(f"Position methods raised AttributeError: {e}") + + print("=== Position methods test PASSED ===") + + +if __name__ == "__main__": + unittest.main()