diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py index 16d94cab..0dfd359b 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py @@ -205,6 +205,7 @@ class SettingActivity(Activity): self.keyboard = lv.keyboard(lv.layer_sys()) self.keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) self.keyboard.set_style_min_height(150, 0) + mpos.ui.theme.fix_keyboard_button_style(self.keyboard) # Fix button visibility in light mode self.keyboard.add_flag(lv.obj.FLAG.HIDDEN) self.keyboard.add_event_cb(lambda *args: mpos.ui.anim.smooth_hide(self.keyboard), lv.EVENT.READY, None) self.keyboard.add_event_cb(lambda *args: mpos.ui.anim.smooth_hide(self.keyboard), lv.EVENT.CANCEL, None) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py b/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py index 2d171926..1ff3483d 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.wifi/assets/wifi.py @@ -8,6 +8,7 @@ from mpos.apps import Activity, Intent import mpos.config import mpos.ui.anim +import mpos.ui.theme import mpos.wifi have_network = True @@ -261,6 +262,7 @@ class PasswordPage(Activity): self.keyboard.align(lv.ALIGN.BOTTOM_MID,0,0) self.keyboard.set_textarea(self.password_ta) self.keyboard.set_style_min_height(160, 0) + mpos.ui.theme.fix_keyboard_button_style(self.keyboard) # Fix button visibility in light mode self.keyboard.add_event_cb(lambda *args: self.hide_keyboard(), lv.EVENT.READY, None) self.keyboard.add_event_cb(lambda *args: self.hide_keyboard(), lv.EVENT.CANCEL, None) self.keyboard.add_flag(lv.obj.FLAG.HIDDEN) diff --git a/internal_filesystem/lib/mpos/ui/theme.py b/internal_filesystem/lib/mpos/ui/theme.py index 0e80c738..8de2ed84 100644 --- a/internal_filesystem/lib/mpos/ui/theme.py +++ b/internal_filesystem/lib/mpos/ui/theme.py @@ -1,10 +1,67 @@ import lvgl as lv import mpos.config +# Global style for keyboard button fix +_keyboard_button_fix_style = None +_is_light_mode = True + +def get_keyboard_button_fix_style(): + """ + Get the keyboard button fix style for light mode. + + The LVGL default theme applies bg_color_white to keyboard buttons, + which makes them white-on-white (invisible) in light mode. + This function returns a custom style to override that. + + Returns: + lv.style_t: Style to apply to keyboard buttons, or None if not needed + """ + global _keyboard_button_fix_style, _is_light_mode + + # Only return style in light mode + if not _is_light_mode: + return None + + # Create style if it doesn't exist + if _keyboard_button_fix_style is None: + _keyboard_button_fix_style = lv.style_t() + _keyboard_button_fix_style.init() + + # Set button background to light gray (matches LVGL's intended design) + # This provides contrast against white background + # Using palette_lighten gives us the same gray as used in the theme + gray_color = lv.palette_lighten(lv.PALETTE.GREY, 2) + _keyboard_button_fix_style.set_bg_color(gray_color) + _keyboard_button_fix_style.set_bg_opa(lv.OPA.COVER) + + return _keyboard_button_fix_style + +# On ESP32, the keyboard buttons in light mode have no color, just white, +# which makes them hard to see on the white background. Probably a bug in the +# underlying LVGL or MicroPython or lvgl_micropython. +def fix_keyboard_button_style(keyboard): + """ + Apply keyboard button visibility fix to a keyboard instance. + + Call this function after creating a keyboard to ensure buttons + are visible in light mode. + + Args: + keyboard: The lv.keyboard instance to fix + """ + style = get_keyboard_button_fix_style() + if style: + keyboard.add_style(style, lv.PART.ITEMS) + print(f"Applied keyboard button fix for light mode to keyboard instance") + def set_theme(prefs): + global _is_light_mode + # Load and set theme: theme_light_dark = prefs.get_string("theme_light_dark", "light") # default to a light theme theme_dark_bool = ( theme_light_dark == "dark" ) + _is_light_mode = not theme_dark_bool # Track for keyboard button fix + primary_color = lv.theme_get_color_primary(None) color_string = prefs.get_string("theme_primary_color") if color_string: @@ -18,3 +75,7 @@ def set_theme(prefs): lv.theme_default_init(mpos.ui.main_display._disp_drv, primary_color, lv.color_hex(0xFBDC05), theme_dark_bool, lv.font_montserrat_12) #mpos.ui.main_display.set_theme(theme) # not needed, default theme is applied immediately + + # Recreate keyboard button fix style if mode changed + global _keyboard_button_fix_style + _keyboard_button_fix_style = None # Force recreation with new theme colors diff --git a/tests/test_graphical_keyboard_styling.py b/tests/test_graphical_keyboard_styling.py new file mode 100644 index 00000000..695d0c6c --- /dev/null +++ b/tests/test_graphical_keyboard_styling.py @@ -0,0 +1,379 @@ +""" +Graphical test for on-screen keyboard button styling. + +This test verifies that keyboard buttons have proper visible contrast +in both light and dark modes. It checks for the bug where keyboard buttons +appear white-on-white in light mode on ESP32. + +The test uses two approaches: +1. Programmatic: Query LVGL style properties to verify button background colors +2. Visual: Capture screenshots for manual verification and regression testing + +This test should INITIALLY FAIL, demonstrating the bug before the fix is applied. + +Usage: + Desktop: ./tests/unittest.sh tests/test_graphical_keyboard_styling.py + Device: ./tests/unittest.sh tests/test_graphical_keyboard_styling.py ondevice +""" + +import unittest +import lvgl as lv +import mpos.ui +import mpos.config +import sys +import os +from graphical_test_helper import ( + wait_for_render, + capture_screenshot, +) + + +class TestKeyboardStyling(unittest.TestCase): + """Test suite for keyboard button visibility and styling.""" + + 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 + + # Save current theme setting + prefs = mpos.config.SharedPreferences("theme_settings") + self.original_theme = prefs.get_string("theme_light_dark", "light") + + print(f"\n=== Keyboard Styling Test Setup ===") + print(f"Platform: {sys.platform}") + print(f"Original theme: {self.original_theme}") + + def tearDown(self): + """Clean up after each test method.""" + # Restore original theme + prefs = mpos.config.SharedPreferences("theme_settings") + editor = prefs.edit() + editor.put_string("theme_light_dark", self.original_theme) + editor.commit() + + # Reapply original theme + mpos.ui.theme.set_theme(prefs) + + print("=== Test cleanup complete ===\n") + + def _create_test_keyboard(self): + """ + Create a test keyboard widget for inspection. + + Returns: + tuple: (screen, keyboard, textarea) widgets + """ + # Create a clean screen + screen = lv.obj() + screen.set_size(320, 240) + + # Create a text area for the keyboard to target + textarea = lv.textarea(screen) + textarea.set_size(280, 40) + textarea.align(lv.ALIGN.TOP_MID, 0, 10) + textarea.set_placeholder_text("Type here...") + + # Create the keyboard + keyboard = lv.keyboard(screen) + keyboard.set_textarea(textarea) + keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + keyboard.set_style_min_height(160, 0) + + # Apply the keyboard button fix + mpos.ui.theme.fix_keyboard_button_style(keyboard) + + # Load the screen and wait for rendering + lv.screen_load(screen) + wait_for_render(iterations=20) + + return screen, keyboard, textarea + + def _get_button_background_color(self, keyboard): + """ + Extract the background color of keyboard buttons. + + This queries LVGL's style system to get the actual rendered + background color of the keyboard's button parts (LV_PART_ITEMS). + + Args: + keyboard: LVGL keyboard widget + + Returns: + dict: Color information with 'r', 'g', 'b' values (0-255) + """ + # Get the style property for button background color + # LV_PART_ITEMS is the part that represents individual buttons + bg_color = keyboard.get_style_bg_color(lv.PART.ITEMS) + + # Extract RGB values from LVGL color + # Note: LVGL colors are in RGB565 or RGB888 depending on config + # We convert to RGB888 for comparison + r = lv.color_brightness(bg_color) if hasattr(lv, 'color_brightness') else 0 + + # Try to get RGB components directly + try: + # For LVGL 9.x, colors have direct accessors + 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: + # Fallback: use color as hex value + 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, + 'hex': f"#{color_int:06x}" + } + except: + # Last resort: just store the color object + color_dict = {'color_obj': bg_color} + + return color_dict + + def _get_screen_background_color(self, screen): + """ + Extract the background color of the screen. + + Args: + screen: LVGL screen object + + Returns: + dict: Color information with 'r', 'g', 'b' values (0-255) + """ + bg_color = screen.get_style_bg_color(lv.PART.MAIN) + + 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, + 'hex': f"#{color_int:06x}" + } + except: + color_dict = {'color_obj': bg_color} + + return color_dict + + def _color_contrast_sufficient(self, color1, color2, min_difference=20): + """ + Check if two colors have sufficient contrast. + + Uses simple RGB distance. For production, you might want to use + proper contrast ratio calculation (WCAG). + + Args: + color1: Dict with 'r', 'g', 'b' keys + color2: Dict with 'r', 'g', 'b' keys + min_difference: Minimum RGB distance for sufficient contrast + + Returns: + bool: True if contrast is sufficient + """ + if 'r' not in color1 or 'r' not in color2: + # Can't determine, assume failure + return False + + # Calculate Euclidean distance in RGB space + r_diff = abs(color1['r'] - color2['r']) + g_diff = abs(color1['g'] - color2['g']) + b_diff = abs(color1['b'] - color2['b']) + + # Simple average difference + avg_diff = (r_diff + g_diff + b_diff) / 3 + + print(f" Color 1: RGB({color1['r']}, {color1['g']}, {color1['b']})") + print(f" Color 2: RGB({color2['r']}, {color2['g']}, {color2['b']})") + print(f" Average difference: {avg_diff:.1f} (min required: {min_difference})") + + return avg_diff >= min_difference + + def test_keyboard_buttons_visible_in_light_mode(self): + """ + Test that keyboard buttons are visible in light mode. + + In light mode, the screen background is white. Keyboard buttons + should NOT be white - they should be a light gray color to provide + contrast. + + This test will FAIL initially, demonstrating the bug. + """ + print("\n=== Testing keyboard buttons in LIGHT mode ===") + + # Set theme to light mode + prefs = mpos.config.SharedPreferences("theme_settings") + editor = prefs.edit() + editor.put_string("theme_light_dark", "light") + editor.commit() + + # Apply theme + mpos.ui.theme.set_theme(prefs) + wait_for_render(iterations=10) + + # Create test keyboard + screen, keyboard, textarea = self._create_test_keyboard() + + # Get colors + button_bg = self._get_button_background_color(keyboard) + screen_bg = self._get_screen_background_color(screen) + + print("\nLight mode colors:") + print(f" Screen background: {screen_bg}") + print(f" Button background: {button_bg}") + + # Capture screenshot + screenshot_path = f"{self.screenshot_dir}/keyboard_light_mode.raw" + print(f"\nCapturing screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # Verify contrast + print("\nChecking button/screen contrast...") + has_contrast = self._color_contrast_sufficient(button_bg, screen_bg, min_difference=20) + + # Clean up + lv.screen_load(lv.obj()) + wait_for_render(5) + + # Assert: buttons should have sufficient contrast with background + self.assertTrue( + has_contrast, + f"Keyboard buttons lack sufficient contrast in light mode!\n" + f"Button color: {button_bg}\n" + f"Screen color: {screen_bg}\n" + f"This is the BUG we're trying to fix - buttons are white on white." + ) + + print("=== Light mode test PASSED ===") + + def test_keyboard_buttons_visible_in_dark_mode(self): + """ + Test that keyboard buttons are visible in dark mode. + + In dark mode, buttons should have proper contrast with the + dark background. This typically works correctly. + """ + print("\n=== Testing keyboard buttons in DARK mode ===") + + # Set theme to dark mode + prefs = mpos.config.SharedPreferences("theme_settings") + editor = prefs.edit() + editor.put_string("theme_light_dark", "dark") + editor.commit() + + # Apply theme + mpos.ui.theme.set_theme(prefs) + wait_for_render(iterations=10) + + # Create test keyboard + screen, keyboard, textarea = self._create_test_keyboard() + + # Get colors + button_bg = self._get_button_background_color(keyboard) + screen_bg = self._get_screen_background_color(screen) + + print("\nDark mode colors:") + print(f" Screen background: {screen_bg}") + print(f" Button background: {button_bg}") + + # Capture screenshot + screenshot_path = f"{self.screenshot_dir}/keyboard_dark_mode.raw" + print(f"\nCapturing screenshot: {screenshot_path}") + capture_screenshot(screenshot_path, width=320, height=240) + + # Verify contrast + print("\nChecking button/screen contrast...") + has_contrast = self._color_contrast_sufficient(button_bg, screen_bg, min_difference=20) + + # Clean up + lv.screen_load(lv.obj()) + wait_for_render(5) + + # Assert: buttons should have sufficient contrast + self.assertTrue( + has_contrast, + f"Keyboard buttons lack sufficient contrast in dark mode!\n" + f"Button color: {button_bg}\n" + f"Screen color: {screen_bg}" + ) + + print("=== Dark mode test PASSED ===") + + def test_keyboard_buttons_not_pure_white_in_light_mode(self): + """ + Specific test: In light mode, buttons should NOT be pure white. + + They should be a light gray (approximately RGB(238, 238, 238) or similar). + Pure white (255, 255, 255) means they're invisible on white background. + """ + print("\n=== Testing that buttons are NOT pure white in light mode ===") + + # Set theme to light mode + prefs = mpos.config.SharedPreferences("theme_settings") + editor = prefs.edit() + editor.put_string("theme_light_dark", "light") + editor.commit() + + # Apply theme + mpos.ui.theme.set_theme(prefs) + wait_for_render(iterations=10) + + # Create test keyboard + screen, keyboard, textarea = self._create_test_keyboard() + + # Get button color + button_bg = self._get_button_background_color(keyboard) + + print(f"\nButton background color: {button_bg}") + + # Clean up + lv.screen_load(lv.obj()) + wait_for_render(5) + + # Check if button is pure white (or very close to it) + if 'r' in button_bg: + is_white = (button_bg['r'] >= 250 and + button_bg['g'] >= 250 and + button_bg['b'] >= 250) + + print(f"Is button pure white? {is_white}") + + # Assert: buttons should NOT be pure white + self.assertFalse( + is_white, + f"Keyboard buttons are pure white in light mode!\n" + f"Button color: RGB({button_bg['r']}, {button_bg['g']}, {button_bg['b']})\n" + f"Expected: Light gray around RGB(238, 238, 238) or similar\n" + f"This is the BUG - white buttons on white background are invisible." + ) + else: + # Couldn't extract RGB, fail the test + self.fail(f"Could not extract RGB values from button color: {button_bg}") + + print("=== Pure white test PASSED ===") + + +if __name__ == "__main__": + # Note: This file is executed by unittest.sh which handles unittest.main() + # But we include it here for completeness + unittest.main()