UI: fix on-screen keyboard button color on ESP32 in light mode

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.
This commit is contained in:
Thomas Farstrike
2025-11-15 18:59:12 +01:00
parent a42deed85f
commit 0dc151255a
4 changed files with 443 additions and 0 deletions
@@ -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)
@@ -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)
+61
View File
@@ -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
+379
View File
@@ -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()