You've already forked MicroPythonOS
mirror of
https://github.com/m5stack/MicroPythonOS.git
synced 2026-05-20 11:51:27 -07:00
294 lines
11 KiB
Python
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)
|