From c9f6952971efaa4c2d98ca057347bc8cdc386c3a Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Sun, 16 Nov 2025 20:30:06 +0100 Subject: [PATCH] Fix MposKeyboard layout switch crashes --- internal_filesystem/lib/mpos/ui/keyboard.py | 52 ++++- ...st_graphical_keyboard_default_vs_custom.py | 189 ++++++++++++++++++ 2 files changed, 232 insertions(+), 9 deletions(-) create mode 100644 tests/test_graphical_keyboard_default_vs_custom.py diff --git a/internal_filesystem/lib/mpos/ui/keyboard.py b/internal_filesystem/lib/mpos/ui/keyboard.py index 4c30ad3e..d81b0abc 100644 --- a/internal_filesystem/lib/mpos/ui/keyboard.py +++ b/internal_filesystem/lib/mpos/ui/keyboard.py @@ -40,7 +40,8 @@ class MposKeyboard: LABEL_LETTERS = "abc" LABEL_SPACE = " " - # Keyboard modes (using LVGL's USER modes) + # Keyboard modes - use USER modes for our API + # We'll also register to standard modes to catch LVGL's internal switches MODE_LOWERCASE = lv.keyboard.MODE.USER_1 MODE_UPPERCASE = lv.keyboard.MODE.USER_2 MODE_NUMBERS = lv.keyboard.MODE.USER_3 @@ -62,17 +63,29 @@ class MposKeyboard: # Configure layouts self._setup_layouts() - # Initialize ALL keyboard mode maps (prevents LVGL from using default maps) + # Initialize ALL keyboard mode maps + # Register to BOTH our USER modes AND standard LVGL modes + # This prevents LVGL from using default maps when it internally switches modes + + # Our USER modes (what we use in our API) self._keyboard.set_map(self.MODE_LOWERCASE, self._lowercase_map, self._lowercase_ctrl) self._keyboard.set_map(self.MODE_UPPERCASE, self._uppercase_map, self._uppercase_ctrl) self._keyboard.set_map(self.MODE_NUMBERS, self._numbers_map, self._numbers_ctrl) self._keyboard.set_map(self.MODE_SPECIALS, self._specials_map, self._specials_ctrl) + # ALSO register to standard LVGL modes (what LVGL uses internally) + # This catches cases where LVGL internally calls set_mode(TEXT_LOWER) + self._keyboard.set_map(lv.keyboard.MODE.TEXT_LOWER, self._lowercase_map, self._lowercase_ctrl) + self._keyboard.set_map(lv.keyboard.MODE.TEXT_UPPER, self._uppercase_map, self._uppercase_ctrl) + self._keyboard.set_map(lv.keyboard.MODE.NUMBER, self._numbers_map, self._numbers_ctrl) + self._keyboard.set_map(lv.keyboard.MODE.SPECIAL, self._specials_map, self._specials_ctrl) + # 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) + # We need to handle ALL events to catch mode changes that LVGL might trigger + self._keyboard.add_event_cb(self._handle_events, lv.EVENT.ALL, None) # Apply theme fix for light mode visibility mpos.ui.theme.fix_keyboard_button_style(self._keyboard) @@ -126,11 +139,28 @@ class MposKeyboard: Args: event: LVGL event object """ - # Only process VALUE_CHANGED events event_code = event.get_code() + + # Intercept READY event to prevent LVGL from changing modes + if event_code == lv.EVENT.READY: + # Stop LVGL from processing READY (which might trigger mode changes) + event.stop_processing() + # Forward READY event to external handlers if needed + return + + # Intercept CANCEL event similarly + if event_code == lv.EVENT.CANCEL: + event.stop_processing() + return + + # Only process VALUE_CHANGED events for actual typing if event_code != lv.EVENT.VALUE_CHANGED: return + # Stop event propagation FIRST, before doing anything else + # This prevents LVGL's default handler from interfering + event.stop_processing() + # Get the pressed button and its text button = self._keyboard.get_selected_button() text = self._keyboard.get_button_text(button) @@ -139,11 +169,6 @@ class MposKeyboard: if text is None: return - # Stop event propagation to prevent LVGL's default mode-switching behavior - # This is critical to prevent LVGL from switching to its default TEXT_LOWER, - # TEXT_UPPER, NUMBER modes when it sees mode-switching buttons - event.stop_processing() - # Get current textarea content (from our own reference, not LVGL's) ta = self._textarea if not ta: @@ -231,17 +256,26 @@ class MposKeyboard: Args: mode: One of MODE_LOWERCASE, MODE_UPPERCASE, MODE_NUMBERS, MODE_SPECIALS + (can also accept standard LVGL modes) """ # Map mode constants to their corresponding map arrays + # Support both our USER modes and standard LVGL modes mode_maps = { self.MODE_LOWERCASE: (self._lowercase_map, self._lowercase_ctrl), self.MODE_UPPERCASE: (self._uppercase_map, self._uppercase_ctrl), self.MODE_NUMBERS: (self._numbers_map, self._numbers_ctrl), self.MODE_SPECIALS: (self._specials_map, self._specials_ctrl), + # Also map standard LVGL modes + lv.keyboard.MODE.TEXT_LOWER: (self._lowercase_map, self._lowercase_ctrl), + lv.keyboard.MODE.TEXT_UPPER: (self._uppercase_map, self._uppercase_ctrl), + lv.keyboard.MODE.NUMBER: (self._numbers_map, self._numbers_ctrl), + lv.keyboard.MODE.SPECIAL: (self._specials_map, self._specials_ctrl), } if mode in mode_maps: key_map, ctrl_map = mode_maps[mode] + # CRITICAL: Always call set_map() BEFORE set_mode() + # This prevents lv_keyboard_update_map() crashes self._keyboard.set_map(mode, key_map, ctrl_map) self._keyboard.set_mode(mode) diff --git a/tests/test_graphical_keyboard_default_vs_custom.py b/tests/test_graphical_keyboard_default_vs_custom.py new file mode 100644 index 00000000..264169eb --- /dev/null +++ b/tests/test_graphical_keyboard_default_vs_custom.py @@ -0,0 +1,189 @@ +""" +Test comparing default LVGL keyboard with custom MposKeyboard. + +This test helps identify the differences between the two keyboard types +so we can properly detect when the bug occurs (switching to default instead of custom). + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_keyboard_default_vs_custom.py +""" + +import unittest +import lvgl as lv +from mpos.ui.keyboard import MposKeyboard +from graphical_test_helper import wait_for_render + + +class TestDefaultVsCustomKeyboard(unittest.TestCase): + """Compare default LVGL keyboard with custom MposKeyboard.""" + + def setUp(self): + """Set up test fixtures.""" + self.screen = lv.obj() + self.screen.set_size(320, 240) + lv.screen_load(self.screen) + wait_for_render(5) + + def tearDown(self): + """Clean up.""" + lv.screen_load(lv.obj()) + wait_for_render(5) + + def test_default_lvgl_keyboard_layout(self): + """ + Examine the default LVGL keyboard to understand its layout. + + This helps us know what we're looking for when detecting the bug. + """ + print("\n=== Examining DEFAULT LVGL keyboard ===") + + # Create textarea + textarea = lv.textarea(self.screen) + textarea.set_size(280, 40) + textarea.align(lv.ALIGN.TOP_MID, 0, 10) + textarea.set_one_line(True) + wait_for_render(5) + + # Create DEFAULT LVGL keyboard + keyboard = lv.keyboard(self.screen) + keyboard.set_textarea(textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + print("\nDefault LVGL keyboard buttons (first 40):") + found_special_labels = {} + for i in range(40): + try: + text = keyboard.get_button_text(i) + if text and text not in ["\n", ""]: + print(f" Index {i}: '{text}'") + # Track special labels + if text in ["ABC", "abc", "1#", "?123", "#+=", lv.SYMBOL.UP, lv.SYMBOL.DOWN]: + found_special_labels[text] = i + except: + pass + + print("\n--- DEFAULT LVGL keyboard has these special labels ---") + for label, idx in found_special_labels.items(): + print(f" '{label}' at index {idx}") + + print("\n--- Characteristics of DEFAULT LVGL keyboard ---") + if "ABC" in found_special_labels: + print(" ✓ Has 'ABC' (uppercase label)") + if "1#" in found_special_labels: + print(" ✓ Has '1#' (numbers label)") + if "#+" in found_special_labels or "#+=" in found_special_labels: + print(" ✓ Has '#+=/-' type labels") + + def test_custom_mpos_keyboard_layout(self): + """ + Examine our custom MposKeyboard to understand its layout. + + This shows what the CORRECT layout should look like. + """ + print("\n=== Examining CUSTOM MposKeyboard ===") + + # Create textarea + textarea = lv.textarea(self.screen) + textarea.set_size(280, 40) + textarea.align(lv.ALIGN.TOP_MID, 0, 10) + textarea.set_one_line(True) + wait_for_render(5) + + # Create CUSTOM MposKeyboard + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + print("\nCustom MposKeyboard buttons (first 40):") + found_special_labels = {} + for i in range(40): + try: + text = keyboard.get_button_text(i) + if text and text not in ["\n", ""]: + print(f" Index {i}: '{text}'") + # Track special labels + if text in ["ABC", "abc", "1#", "?123", "=\\<", lv.SYMBOL.UP, lv.SYMBOL.DOWN]: + found_special_labels[text] = i + except: + pass + + print("\n--- CUSTOM MposKeyboard has these special labels ---") + for label, idx in found_special_labels.items(): + print(f" '{label}' at index {idx}") + + print("\n--- Characteristics of CUSTOM MposKeyboard ---") + if "?123" in found_special_labels: + print(" ✓ Has '?123' (numbers label)") + if "=\\<" in found_special_labels: + print(" ✓ Has '=\\<' (specials label)") + if lv.SYMBOL.UP in found_special_labels: + print(" ✓ Has UP symbol (shift to uppercase)") + + def test_mode_switching_bug_reproduction(self): + """ + Try to reproduce the bug: numbers -> abc -> wrong layout. + """ + print("\n=== Attempting to reproduce the bug ===") + + textarea = lv.textarea(self.screen) + textarea.set_size(280, 40) + textarea.align(lv.ALIGN.TOP_MID, 0, 10) + textarea.set_one_line(True) + wait_for_render(5) + + keyboard = MposKeyboard(self.screen) + keyboard.set_textarea(textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + wait_for_render(10) + + # Step 1: Start in lowercase + print("\nStep 1: Initial lowercase mode") + labels_step1 = self._get_special_labels(keyboard) + print(f" Labels: {list(labels_step1.keys())}") + self.assertIn("?123", labels_step1, "Should start with custom lowercase (?123)") + + # Step 2: Switch to numbers + print("\nStep 2: Switch to numbers mode") + keyboard.set_mode(MposKeyboard.MODE_NUMBERS) + wait_for_render(5) + labels_step2 = self._get_special_labels(keyboard) + print(f" Labels: {list(labels_step2.keys())}") + self.assertIn("abc", labels_step2, "Should have 'abc' in numbers mode") + + # Step 3: Switch back to lowercase (this is where bug might happen) + print("\nStep 3: Switch back to lowercase via set_mode()") + keyboard.set_mode(MposKeyboard.MODE_LOWERCASE) + wait_for_render(5) + labels_step3 = self._get_special_labels(keyboard) + print(f" Labels: {list(labels_step3.keys())}") + + # Check for bug + if "ABC" in labels_step3 or "1#" in labels_step3: + print(" ❌ BUG DETECTED: Got default LVGL keyboard!") + print(f" Found these labels: {list(labels_step3.keys())}") + self.fail("BUG: Switched to default LVGL keyboard instead of custom") + + if "?123" not in labels_step3: + print(" ❌ BUG DETECTED: Missing '?123' label!") + print(f" Found these labels: {list(labels_step3.keys())}") + self.fail("BUG: Missing '?123' label from custom keyboard") + + print(" ✓ Correct: Has custom layout with '?123'") + + def _get_special_labels(self, keyboard): + """Helper to get special labels from keyboard.""" + labels = {} + for i in range(100): + try: + text = keyboard.get_button_text(i) + if text in ["ABC", "abc", "1#", "?123", "=\\<", "#+=", lv.SYMBOL.UP, lv.SYMBOL.DOWN]: + labels[text] = i + except: + pass + return labels + + +if __name__ == "__main__": + unittest.main()