Add AppearanceManager

This commit is contained in:
Thomas Farstrike
2026-01-23 22:26:01 +01:00
parent 9bbab6a908
commit 5b5ac9c006
7 changed files with 309 additions and 23 deletions
@@ -10,7 +10,7 @@
# Most of this time is actually spent reading and parsing manifests.
import lvgl as lv
import mpos.apps
from mpos import NOTIFICATION_BAR_HEIGHT, PackageManager, Activity, DisplayMetrics
from mpos import AppearanceManager, PackageManager, Activity, DisplayMetrics
import time
import uhashlib
import ubinascii
@@ -28,9 +28,9 @@ class Launcher(Activity):
main_screen = lv.obj()
main_screen.set_style_border_width(0, lv.PART.MAIN)
main_screen.set_style_radius(0, 0)
main_screen.set_pos(0, NOTIFICATION_BAR_HEIGHT)
main_screen.set_pos(0, AppearanceManager.NOTIFICATION_BAR_HEIGHT)
main_screen.set_style_pad_hor(DisplayMetrics.pct_of_width(2), 0)
main_screen.set_style_pad_ver(NOTIFICATION_BAR_HEIGHT, 0)
main_screen.set_style_pad_ver(AppearanceManager.NOTIFICATION_BAR_HEIGHT, 0)
main_screen.set_flex_flow(lv.FLEX_FLOW.ROW_WRAP)
self.setContentView(main_screen)
+5 -5
View File
@@ -32,10 +32,10 @@ from .ui.testing import (
# UI utility functions
from .ui.display_metrics import DisplayMetrics
from .ui.appearance_manager import AppearanceManager
from .ui.event import get_event_name, print_event
from .ui.view import setContentView, back_screen
from .ui.theme import set_theme
from .ui.topmenu import open_bar, close_bar, open_drawer, drawer_open, NOTIFICATION_BAR_HEIGHT
from .ui.topmenu import open_bar, close_bar, open_drawer, drawer_open
from .ui.focus import save_and_clear_current_focusgroup
from .ui.gesture_navigation import handle_back_swipe, handle_top_swipe
from .ui.util import shutdown, set_foreground_app, get_foreground_app
@@ -69,12 +69,12 @@ __all__ = [
"SettingActivity", "SettingsActivity", "CameraActivity",
# UI components
"MposKeyboard",
# UI utility - DisplayMetrics
# UI utility - DisplayMetrics and AppearanceManager
"DisplayMetrics",
"AppearanceManager",
"get_event_name", "print_event",
"setContentView", "back_screen",
"set_theme",
"open_bar", "close_bar", "open_drawer", "drawer_open", "NOTIFICATION_BAR_HEIGHT",
"open_bar", "close_bar", "open_drawer", "drawer_open",
"save_and_clear_current_focusgroup",
"handle_back_swipe", "handle_top_swipe",
"shutdown", "set_foreground_app", "get_foreground_app",
+2 -1
View File
@@ -8,6 +8,7 @@ import mpos.ui
from . import ui
from .content.package_manager import PackageManager
from mpos.ui.display import init_rootscreen
from mpos.ui.appearance_manager import AppearanceManager
import mpos.ui.topmenu
# Auto-detect and initialize hardware
@@ -41,7 +42,7 @@ mpos.fs_driver.fs_register(fs_drv, 'M')
prefs = mpos.config.SharedPreferences("com.micropythonos.settings")
mpos.ui.set_theme(prefs)
AppearanceManager.init(prefs)
init_rootscreen()
mpos.ui.topmenu.create_notification_bar()
mpos.ui.topmenu.create_drawer(mpos.ui.display)
+4 -4
View File
@@ -3,8 +3,8 @@ from .view import (
screen_stack, remove_and_stop_current_activity, remove_and_stop_all_activities
)
from .gesture_navigation import handle_back_swipe, handle_top_swipe
from .theme import set_theme
from .topmenu import open_bar, close_bar, open_drawer, drawer_open, NOTIFICATION_BAR_HEIGHT
from .appearance_manager import AppearanceManager
from .topmenu import open_bar, close_bar, open_drawer, drawer_open
from .focus import save_and_clear_current_focusgroup
from .display_metrics import DisplayMetrics
from .event import get_event_name, print_event
@@ -20,8 +20,8 @@ main_display = None
__all__ = [
"setContentView", "back_screen", "remove_and_stop_current_activity", "remove_and_stop_all_activities",
"handle_back_swipe", "handle_top_swipe",
"set_theme",
"open_bar", "close_bar", "open_drawer", "drawer_open", "NOTIFICATION_BAR_HEIGHT",
"AppearanceManager",
"open_bar", "close_bar", "open_drawer", "drawer_open",
"save_and_clear_current_focusgroup",
"DisplayMetrics",
"get_event_name", "print_event",
@@ -0,0 +1,285 @@
# lib/mpos/ui/appearance_manager.py
"""
AppearanceManager - Android-inspired appearance management singleton.
Manages all aspects of the app's visual appearance:
- Light/dark mode (UI appearance)
- Theme colors (primary, secondary, accent)
- UI dimensions (notification bar height, etc.)
- LVGL theme initialization
- Keyboard styling workarounds
This is a singleton implemented using class methods and class variables.
No instance creation is needed - all methods are class methods.
Example:
from mpos import AppearanceManager
# Check light/dark mode
if AppearanceManager.is_light_mode():
print("Light mode enabled")
# Get UI dimensions
bar_height = AppearanceManager.get_notification_bar_height()
# Initialize appearance from preferences
AppearanceManager.init(prefs)
"""
import lvgl as lv
class AppearanceManager:
"""
Android-inspired appearance management singleton.
Centralizes all UI appearance settings including theme colors, light/dark mode,
and UI dimensions. Follows the singleton pattern using class methods and class
variables, similar to Android's Configuration and Resources classes.
All methods are class methods - no instance creation needed.
"""
# ========== UI Dimensions ==========
# These are constants that define the layout of the UI
NOTIFICATION_BAR_HEIGHT = 24 # Height of the notification bar in pixels
# ========== Private Class Variables ==========
# State variables shared across all "instances" (there is only one logical instance)
_is_light_mode = True
_primary_color = None
_accent_color = None
_keyboard_button_fix_style = None
# ========== Initialization ==========
@classmethod
def init(cls, prefs):
"""
Initialize AppearanceManager from preferences.
Called during system startup to load theme settings from SharedPreferences
and initialize the LVGL theme. This should be called once during boot.
Args:
prefs: SharedPreferences object containing theme settings
- "theme_light_dark": "light" or "dark" (default: "light")
- "theme_primary_color": hex color string like "0xFF5722" or "#FF5722"
Example:
from mpos import AppearanceManager
import mpos.config
prefs = mpos.config.get_shared_preferences()
AppearanceManager.init(prefs)
"""
# Load light/dark mode preference
theme_light_dark = prefs.get_string("theme_light_dark", "light")
theme_dark_bool = (theme_light_dark == "dark")
cls._is_light_mode = not theme_dark_bool
# Load primary color preference
primary_color = lv.theme_get_color_primary(None)
color_string = prefs.get_string("theme_primary_color")
if color_string:
try:
color_string = color_string.replace("0x", "").replace("#", "").strip().lower()
color_int = int(color_string, 16)
print(f"[AppearanceManager] Setting primary color: {color_int}")
primary_color = lv.color_hex(color_int)
cls._primary_color = primary_color
except Exception as e:
print(f"[AppearanceManager] Converting color setting '{color_string}' failed: {e}")
# Initialize LVGL theme with loaded settings
# Get the display driver from the active screen
screen = lv.screen_active()
disp = screen.get_display()
lv.theme_default_init(
disp,
primary_color,
lv.color_hex(0xFBDC05), # Accent color (yellow)
theme_dark_bool,
lv.font_montserrat_12
)
# Reset keyboard button fix style so it's recreated with new theme colors
cls._keyboard_button_fix_style = None
print(f"[AppearanceManager] Initialized: light_mode={cls._is_light_mode}, primary_color={primary_color}")
# ========== Light/Dark Mode ==========
@classmethod
def is_light_mode(cls):
"""
Check if light mode is currently enabled.
Returns:
bool: True if light mode is enabled, False if dark mode is enabled
Example:
from mpos import AppearanceManager
if AppearanceManager.is_light_mode():
print("Using light theme")
else:
print("Using dark theme")
"""
return cls._is_light_mode
@classmethod
def set_light_mode(cls, is_light, prefs=None):
"""
Set light/dark mode and update the theme.
Args:
is_light (bool): True for light mode, False for dark mode
prefs (SharedPreferences, optional): If provided, saves the setting
Example:
from mpos import AppearanceManager
AppearanceManager.set_light_mode(False) # Switch to dark mode
"""
cls._is_light_mode = is_light
# Save to preferences if provided
if prefs:
theme_str = "light" if is_light else "dark"
prefs.set_string("theme_light_dark", theme_str)
# Reinitialize LVGL theme with new mode
if prefs:
cls.init(prefs)
print(f"[AppearanceManager] Light mode set to: {is_light}")
# ========== Theme Colors ==========
@classmethod
def get_primary_color(cls):
"""
Get the primary theme color.
Returns:
lv.color_t: The primary color, or None if not set
Example:
from mpos import AppearanceManager
color = AppearanceManager.get_primary_color()
if color:
button.set_style_bg_color(color, 0)
"""
return cls._primary_color
@classmethod
def set_primary_color(cls, color, prefs=None):
"""
Set the primary theme color.
Args:
color (lv.color_t or int): The new primary color
prefs (SharedPreferences, optional): If provided, saves the setting
Example:
from mpos import AppearanceManager
import lvgl as lv
AppearanceManager.set_primary_color(lv.color_hex(0xFF5722))
"""
cls._primary_color = color
# Save to preferences if provided
if prefs and isinstance(color, int):
prefs.set_string("theme_primary_color", f"0x{color:06X}")
print(f"[AppearanceManager] Primary color set to: {color}")
# ========== UI Dimensions ==========
@classmethod
def get_notification_bar_height(cls):
"""
Get the height of the notification bar.
The notification bar is the top bar that displays system information
(time, battery, signal, etc.). This method returns its height in pixels.
Returns:
int: Height of the notification bar in pixels (default: 24)
Example:
from mpos import AppearanceManager
bar_height = AppearanceManager.get_notification_bar_height()
content_y = bar_height # Position content below the bar
"""
return cls.NOTIFICATION_BAR_HEIGHT
# ========== Keyboard Styling Workarounds ==========
@classmethod
def get_keyboard_button_fix_style(cls):
"""
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 method returns a custom style to override that.
Returns:
lv.style_t: Style to apply to keyboard buttons, or None if not needed
Note:
This is a workaround for an LVGL/MicroPython issue. It only applies
in light mode. In dark mode, the default LVGL styling is fine.
Example:
from mpos import AppearanceManager
style = AppearanceManager.get_keyboard_button_fix_style()
if style:
keyboard.add_style(style, lv.PART.ITEMS)
"""
# Only return style in light mode
if not cls._is_light_mode:
return None
# Create style if it doesn't exist
if cls._keyboard_button_fix_style is None:
cls._keyboard_button_fix_style = lv.style_t()
cls._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)
cls._keyboard_button_fix_style.set_bg_color(gray_color)
cls._keyboard_button_fix_style.set_bg_opa(lv.OPA.COVER)
return cls._keyboard_button_fix_style
@classmethod
def apply_keyboard_fix(cls, 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
Example:
from mpos import AppearanceManager
import lvgl as lv
keyboard = lv.keyboard(screen)
AppearanceManager.apply_keyboard_fix(keyboard)
"""
style = cls.get_keyboard_button_fix_style()
if style:
keyboard.add_style(style, lv.PART.ITEMS)
print(f"[AppearanceManager] Applied keyboard button fix for light mode")
@@ -4,6 +4,7 @@ from .widget_animator import WidgetAnimator
from .view import back_screen
from mpos.ui import topmenu as topmenu
from .display import DisplayMetrics
from .appearance_manager import AppearanceManager
downbutton = None
backbutton = None
@@ -106,10 +107,10 @@ def _top_swipe_cb(event):
def handle_back_swipe():
global backbutton
rect = lv.obj(lv.layer_top())
rect.set_size(topmenu.NOTIFICATION_BAR_HEIGHT, lv.layer_top().get_height()-topmenu.NOTIFICATION_BAR_HEIGHT) # narrow because it overlaps buttons
rect.set_size(AppearanceManager.NOTIFICATION_BAR_HEIGHT, lv.layer_top().get_height()-AppearanceManager.NOTIFICATION_BAR_HEIGHT) # narrow because it overlaps buttons
rect.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF)
rect.set_scroll_dir(lv.DIR.NONE)
rect.set_pos(0, topmenu.NOTIFICATION_BAR_HEIGHT)
rect.set_pos(0, AppearanceManager.NOTIFICATION_BAR_HEIGHT)
style = lv.style_t()
style.init()
style.set_bg_opa(lv.OPA.TRANSP)
@@ -137,7 +138,7 @@ def handle_back_swipe():
def handle_top_swipe():
global downbutton
rect = lv.obj(lv.layer_top())
rect.set_size(lv.pct(100), topmenu.NOTIFICATION_BAR_HEIGHT)
rect.set_size(lv.pct(100), AppearanceManager.NOTIFICATION_BAR_HEIGHT)
rect.set_pos(0, 0)
rect.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF)
style = lv.style_t()
+6 -7
View File
@@ -3,12 +3,11 @@ import lvgl as lv
import mpos.time
import mpos.battery_voltage
from .display_metrics import DisplayMetrics
from .appearance_manager import AppearanceManager
from .util import (get_foreground_app)
from . import focus_direction
from .widget_animator import WidgetAnimator
NOTIFICATION_BAR_HEIGHT=24
CLOCK_UPDATE_INTERVAL = 1000 # 10 or even 1 ms doesn't seem to change the framerate but 100ms is enough
WIFI_ICON_UPDATE_INTERVAL = 1500
BATTERY_ICON_UPDATE_INTERVAL = 15000 # not too often, but not too short, otherwise it takes a while to appear
@@ -20,7 +19,7 @@ DRAWER_ANIM_DURATION=300
hide_bar_animation = None
show_bar_animation = None
show_bar_animation_start_value = -NOTIFICATION_BAR_HEIGHT
show_bar_animation_start_value = -AppearanceManager.NOTIFICATION_BAR_HEIGHT
show_bar_animation_end_value = 0
hide_bar_animation_start_value = show_bar_animation_end_value
hide_bar_animation_end_value = show_bar_animation_start_value
@@ -80,7 +79,7 @@ def create_notification_bar():
global notification_bar
# Create notification bar
notification_bar = lv.obj(lv.layer_top())
notification_bar.set_size(lv.pct(100), NOTIFICATION_BAR_HEIGHT)
notification_bar.set_size(lv.pct(100), AppearanceManager.NOTIFICATION_BAR_HEIGHT)
notification_bar.set_pos(0, show_bar_animation_start_value)
notification_bar.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF)
notification_bar.set_scroll_dir(lv.DIR.NONE)
@@ -203,7 +202,7 @@ def create_notification_bar():
hide_bar_animation = lv.anim_t()
hide_bar_animation.init()
hide_bar_animation.set_var(notification_bar)
hide_bar_animation.set_values(0, -NOTIFICATION_BAR_HEIGHT)
hide_bar_animation.set_values(0, -AppearanceManager.NOTIFICATION_BAR_HEIGHT)
hide_bar_animation.set_duration(2000)
hide_bar_animation.set_custom_exec_cb(lambda not_used, value : notification_bar.set_y(value))
@@ -222,7 +221,7 @@ def create_drawer(display=None):
global drawer
drawer=lv.obj(lv.layer_top())
drawer.set_size(lv.pct(100),lv.pct(90))
drawer.set_pos(0,NOTIFICATION_BAR_HEIGHT)
drawer.set_pos(0,AppearanceManager.NOTIFICATION_BAR_HEIGHT)
drawer.set_scroll_dir(lv.DIR.VER)
drawer.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF)
drawer.set_style_pad_all(15, 0)
@@ -381,7 +380,7 @@ def drawer_scroll_callback(event):
elif event_code == lv.EVENT.SCROLL and scroll_start_y != None:
diff = y - scroll_start_y
#print(f"scroll distance: {diff}")
if diff < -NOTIFICATION_BAR_HEIGHT:
if diff < -AppearanceManager.NOTIFICATION_BAR_HEIGHT:
close_drawer()
elif event_code == lv.EVENT.SCROLL_END:
scroll_start_y = None