Files
Thomas Farstrike 33862749b7 Comments
2026-02-23 23:33:48 +01:00

294 lines
11 KiB
Python

"""
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 MposKeyboard
# Create keyboard
keyboard = MposKeyboard(parent_obj)
keyboard.set_textarea(my_textarea)
keyboard.add_flag(lv.obj.FLAG.HIDDEN) # shows up when textarea is clicked
"""
import lvgl as lv
from .appearance_manager import AppearanceManager
from .widget_animator import WidgetAnimator
class MposKeyboard:
"""
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 - 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
MODE_SPECIALS = lv.keyboard.MODE.USER_4
# 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",
LABEL_NUMBERS_SPECIALS, ",", LABEL_SPACE, ".", lv.SYMBOL.OK, None
]
_lowercase_ctrl = [lv.buttonmatrix.CTRL.WIDTH_10] * len(_lowercase_map)
_lowercase_ctrl[29] = lv.buttonmatrix.CTRL.WIDTH_5 # comma
_lowercase_ctrl[30] = lv.buttonmatrix.CTRL.WIDTH_15 # space
_lowercase_ctrl[31] = lv.buttonmatrix.CTRL.WIDTH_5 # dot
# 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",
LABEL_NUMBERS_SPECIALS, ",", LABEL_SPACE, ".", lv.SYMBOL.OK, None
]
_uppercase_ctrl = [lv.buttonmatrix.CTRL.WIDTH_10] * len(_uppercase_map)
_uppercase_ctrl[29] = lv.buttonmatrix.CTRL.WIDTH_5 # comma
_uppercase_ctrl[30] = lv.buttonmatrix.CTRL.WIDTH_15 # space
_uppercase_ctrl[31] = lv.buttonmatrix.CTRL.WIDTH_5 # dot
# Numbers and common special characters
_numbers_map = [
"1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "\n",
"@", "#", "$", "_", "&", "-", "+", "(", ")", "/", "\n",
LABEL_SPECIALS, "*", "\"", "'", ":", ";", "!", "?", lv.SYMBOL.BACKSPACE, "\n",
LABEL_LETTERS, ",", LABEL_SPACE, ".", lv.SYMBOL.OK, None
]
_numbers_ctrl = [lv.buttonmatrix.CTRL.WIDTH_10] * len(_numbers_map)
_numbers_ctrl[30] = lv.buttonmatrix.CTRL.WIDTH_5 # comma
_numbers_ctrl[31] = lv.buttonmatrix.CTRL.WIDTH_15 # space
_numbers_ctrl[32] = lv.buttonmatrix.CTRL.WIDTH_5 # dot
# Additional special characters with emoticons
_specials_map = [
"~", "`", "|", "•", ":-)", ";-)", ":-D", "\n",
":-(" , ":'-(", "^", "°", "=", "{", "}", "\\", "\n",
LABEL_NUMBERS_SPECIALS, "%", ":-o", ":-P", "[", "]", lv.SYMBOL.BACKSPACE, "\n",
LABEL_LETTERS, "<", LABEL_SPACE, ">", lv.SYMBOL.OK, None
]
_specials_ctrl = [lv.buttonmatrix.CTRL.WIDTH_10] * len(_specials_map)
_specials_ctrl[15] = lv.buttonmatrix.CTRL.WIDTH_15 # LABEL_NUMBERS_SPECIALS is pretty wide
_specials_ctrl[23] = lv.buttonmatrix.CTRL.WIDTH_5 # <
_specials_ctrl[24] = lv.buttonmatrix.CTRL.WIDTH_15 # space
_specials_ctrl[25] = lv.buttonmatrix.CTRL.WIDTH_5 # >
# Map modes to their layouts
mode_info = {
MODE_LOWERCASE: (_lowercase_map, _lowercase_ctrl),
MODE_UPPERCASE: (_uppercase_map, _uppercase_ctrl),
MODE_NUMBERS: (_numbers_map, _numbers_ctrl),
MODE_SPECIALS: (_specials_map, _specials_ctrl),
}
_current_mode = None
_parent = None # used for scroll_to_y
_saved_scroll_y = 0
# Store textarea reference (we DON'T pass it to LVGL to avoid double-typing)
_textarea = None
def __init__(self, parent):
# Create underlying LVGL keyboard widget
self._keyboard = lv.keyboard(parent)
self._parent = parent # store it for later
# self._keyboard.set_popovers(True) # disabled for now because they're quite ugly on LVGL 9.3 - maybe better on 9.4?
self._keyboard.set_style_text_font(lv.font_montserrat_20, lv.PART.MAIN)
self.set_mode(self.MODE_LOWERCASE)
# Remove default event handler(s)
for index in range(self._keyboard.get_event_count()):
self._keyboard.remove_event(index)
self._keyboard.add_event_cb(self._handle_events, lv.EVENT.ALL, None)
# Apply theme fix for light mode visibility
AppearanceManager.apply_keyboard_fix(self._keyboard)
# Set good default height
self._keyboard.set_style_min_height(175, lv.PART.MAIN)
def _handle_events(self, event):
code = event.get_code()
'''
# DEBUG:
from .event import get_event_name
name = get_event_name(code)
print(f"keyboard event code = {code} is {name}")
'''
if code == lv.EVENT.READY or code == lv.EVENT.CANCEL:
self.hide_keyboard()
return
# Process VALUE_CHANGED events for actual typing
if code != lv.EVENT.VALUE_CHANGED:
return
# Get the pressed button and its text
target_obj=event.get_target_obj() # keyboard
if not target_obj:
return
button = target_obj.get_selected_button()
if button is None:
return
text = target_obj.get_button_text(button)
#print(f"[KBD] btn={button}, mode={self._current_mode}, text='{text}'")
# Ignore if no valid button text (can happen during mode switching)
if text is None:
return
# Get current textarea content (from our own reference, not LVGL's)
ta = self._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.set_mode(self.MODE_UPPERCASE)
return # Don't modify text
elif text == lv.SYMBOL.DOWN or text == self.LABEL_LETTERS:
# Switch to lowercase
self.set_mode(self.MODE_LOWERCASE)
return # Don't modify text
elif text == self.LABEL_NUMBERS_SPECIALS:
# Switch to numbers/specials
self.set_mode(self.MODE_NUMBERS)
return # Don't modify text
elif text == self.LABEL_SPECIALS:
# Switch to additional specials
self.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.OK:
self._keyboard.send_event(lv.EVENT.READY, None)
return
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)
def set_textarea(self, textarea):
"""
Set the textarea that this keyboard types into.
IMPORTANT: We store the textarea reference ourselves and DON'T pass
it to the underlying LVGL keyboard. This prevents LVGL's built-in
automatic character insertion, which would cause double-character bugs
(LVGL inserts + our handler inserts = double characters).
Args:
textarea: The lv.textarea widget to type into, or None to disconnect
"""
self._textarea = textarea
# NOTE: We deliberately DO NOT call self._keyboard.set_textarea()
# to avoid LVGL's automatic character insertion
self._textarea.add_event_cb(lambda *args: self.show_keyboard(), lv.EVENT.CLICKED, None)
def get_textarea(self):
"""
Get the textarea that this keyboard types into.
Returns:
The lv.textarea widget, or None if not connected
"""
return self._textarea
def set_mode(self, mode):
#print(f"[kbc] setting mode to {mode}")
self._current_mode = mode
key_map, ctrl_map = self.mode_info[mode]
self._keyboard.set_map(mode, key_map, ctrl_map)
self._keyboard.set_mode(mode)
def scroll_after_show(self, timer):
#self._textarea.scroll_to_view_recursive(True) # makes sense but doesn't work and breaks the keyboard scroll
self._keyboard.scroll_to_view_recursive(True)
def focus_on_keyboard(self, timer=None):
default_group = lv.group_get_default()
if default_group:
from .input_manager import InputManager
from .focus_direction import move_focus_direction
InputManager.emulate_focus_obj(default_group, self._keyboard)
def scroll_back_after_hide(self, timer):
self._parent.scroll_to_y(self._saved_scroll_y, True)
def show_keyboard(self):
self._saved_scroll_y = self._parent.get_scroll_y()
WidgetAnimator.smooth_show(self._keyboard, duration=500)
# Scroll to view on a timer because it will be hidden initially
lv.timer_create(self.scroll_after_show, 250, None).set_repeat_count(1)
# When this is done from a timer, focus styling is not applied so the user doesn't see which button is selected.
# Maybe because there's no active indev anymore?
# Maybe it will be fixed in an update of LVGL 9.3?
# focus_timer = lv.timer_create(self.focus_on_keyboard,750,None).set_repeat_count(1)
# Workaround: show the keyboard immediately and then focus on it - that works, and doesn't seem to flicker as feared:
self._keyboard.remove_flag(lv.obj.FLAG.HIDDEN)
self.focus_on_keyboard()
def hide_keyboard(self):
WidgetAnimator.smooth_hide(self._keyboard, duration=500)
# Do this after the hide so the scrollbars disappear automatically if not needed
scroll_timer = lv.timer_create(self.scroll_back_after_hide,550,None).set_repeat_count(1)
# Python magic method for automatic method forwarding
def __getattr__(self, name):
#print(f"[kbd] __getattr__ {name}")
"""
Forward any undefined method/attribute to the underlying LVGL keyboard.
This allows MposKeyboard to support ALL lv.keyboard methods automatically
without needing to manually wrap each one. Any method not defined on
MposKeyboard will be forwarded to self._keyboard.
Examples:
keyboard.set_textarea(ta) # Works
keyboard.align(lv.ALIGN.CENTER) # Works
keyboard.set_style_opa(128, lv.PART.MAIN) # Works
keyboard.any_lvgl_method() # Works!
"""
# Forward to the underlying keyboard object
return getattr(self._keyboard, name)