RetroPie-Setup/scriptmodules/admin/joy2key/osk.py
cmitu b6ea0392e9 joy2key: teach the OSK to start with a value
Added a new parameter to `osk.py` to populate the input box with an existing value.
This makes it easier to replace the `dialog`'s `--inputbox` widget, which accepts an existing value as the last argument.

Removed the prefix from the input prompt, let it be handled by the caller.
2023-02-25 18:23:22 +00:00

632 lines
21 KiB
Python

"""
This file is part of The RetroPie Project
The RetroPie Project is the legal property of its developers, whose names are
too numerous to list here. Please refer to the COPYRIGHT.md file distributed with this source.
See the LICENSE.md file at the top-level directory of this distribution and
https://raw.githubusercontent.com/RetroPie/RetroPie-Setup/master/LICENSE.md.
OnScreen Keyboard console utility.
Allows the user to enter a string (ASCII) and outputs the string.
It should be wrapped by 'joy2key' helper script,
so that the gamepad can be used to navigate and enter the necessary characters.
Keys:
- directional keys to move around
- Enter to select a key / press a button
- Esc to exit the form
It uses the [URWID](https://urwid.org) Python library to show a nice console based keyboard.
Example usage:
<script> WindowTitle StringName [min char number]
Exit code can be:
- 0 (success), the string entered by the user is written to STDERR
- 1 (cancel/error) if the user chose cancel to exit
"""
import sys
from os import get_terminal_size
from argparse import ArgumentParser
import urwid
from urwid.widget import Text, Divider
from urwid.container import Columns, Frame, GridFlow, Overlay, Pile, WidgetWrap
from urwid.decoration import AttrMap, AttrWrap, Filler, Padding
from urwid.graphics import LineBox
from urwid.signals import connect_signal
from urwid.command_map import ACTIVATE
ASCII_BLOCK = ''
# What we consider a small screen
SMALL_SCREEN_COLS = 43
SMALL_SCREEN_ROWS = 22
"""
Colors used in the application controls
"""
PALETTE = [
# Input box: border, text and prompt
('input', 'dark gray', 'light gray'),
('input text', 'black', 'light gray'),
('prompt', 'dark red', 'dark cyan' ),
# Body
('body', 'black', 'light gray'),
('bg', 'white', 'dark blue'),
# Focused key
('focus key', 'white', 'dark blue'),
# Header
('header', 'light cyan', 'dark blue'),
('bold header', 'dark cyan', 'dark blue'),
# Buttons
('button', 'black', 'light gray'),
('selected', 'white', 'dark blue' ),
('label', 'dark gray', 'light gray'),
('label selected', 'yellow', 'dark blue' ),
# Error dialog
('error', 'dark red', 'light gray')
]
class CenteredButton(WidgetWrap):
"""
Custom button class that:
* centers the label text
* allows to disable the left/righ button margins/characters
* disables the 'space' key handling
"""
def selectable(self):
return True
def sizing(self):
return frozenset([FLOW])
signals = ["click"]
def __init__(self, label, on_press=None, user_data=None, delimiters=True):
self._label = Text(label, align='center')
if delimiters:
cols = Columns(
[
('fixed', 1, Text("<")),
self._label,
('fixed', 1, Text(">"))
],
dividechars=1)
else:
cols = self._label
self.__super.__init__(cols)
if on_press:
connect_signal(self, 'click', on_press, user_data)
# The rest of the methods are taken from urwid.Button
def set_label(self, label):
self._label.set_text(label)
def get_label(self):
return self._label.text
label = property(get_label)
def keypress(self, size, key):
# don't activate with the 'Space' key
if self._command_map[key] != ACTIVATE or key == ' ':
return key
self._emit('click')
def mouse_event(self, size, event, button, x, y, focus):
return False
class KeyButton(CenteredButton):
"""
Custom button class to model a keyboard key
It has primary and secondary key values, returned based on the shift state
"""
def __init__(self, text, primary=None, secondary=None, on_press=None, user_data=None):
self.__super.__init__(text, on_press, user_data, delimiters=False)
# store the primary and secondary key values
if primary is None:
self.primary_val = text
else:
self.primary_val = primary
# calculate the secondary value when the label is a letter
if secondary is None and len(text) == 1:
self.secondary_val = text.upper()
else:
self.secondary_val = secondary
def shift(self, shifted):
"""
Simulate a shift key press, changing the key's label
This may change the button's appearance
"""
if (shifted
and self.secondary_val is not None
and len(self.secondary_val.strip()) > 0):
self.set_label(self.secondary_val)
if (not shifted
and self.primary_val is not None
and len(self.primary_val.strip()) > 0):
self.set_label(self.primary_val)
def get_value(self, shifted):
if shifted and self.secondary_val:
return self.secondary_val
if not shifted and self.primary_val:
return self.primary_val
class WrappableColumns(Columns):
"""
Custom Columns class
Adds the ability to wrap-around the children (left-right) when navigating
"""
def keypress(self, size, key):
if self.__super.keypress(size, key):
if key not in ('left', 'right'):
return key
# we have a key, so it wasn't handled by any parent container
# handle 'left'/'right' ourselves for cursor wrapping
if key in ('left'):
# iterate from last widget to first
widgets = list(range(len(self.contents) - 1, -1, -1))
else:
# iterate from first widget to last
widgets = list(range(0, len(self.contents)))
# Find the 1st selectable widget and focus it
for i in widgets:
if not self.contents[i][0].selectable():
continue
self.focus_position = i
break
class ViewExit(Exception):
pass
class OSK:
"""
Main class for the on-screen keyboard application
"""
def __init__(self, title, input_title, input_value, min_chars=8, dim=False):
"""
:param title: the string used in the application heading
:param input_title: the name of the input string being captured (e.g. Password)
:param input_value: existing string to populate the input
:param min_chars: minimum number of characters for a valid string (default: 8)
:param dim: optimize for a smaller screen (True/False)
"""
self._input_title = input_title
self._input_value = input_value
self._min_chars = min_chars
self.small_display = dim
self.def_keys = []
self.frame = self.setup_frame(title, input_title, input_value)
self.pop_up = self.setup_popup("Error")
# Create the main view, overlaying the popup widget with the main view
view = Overlay(self.pop_up, self.frame, 'center', None, 'middle', None)
self.view = view
def setup_frame(self, title, input_title, input_value):
"""
Creates the main view, with a 3 horizontal pane container (Frame)
"""
self.keys = [] # List of keys added to the OSK
self._shift = False # OSK Shift key state
# title frame (header) uses a LineBox with just the bottom line enabled
# if we're on a small display, use a simple Text with Padding
if self.small_display:
header = Padding(Text(title, align='center'))
else:
header = LineBox(Text(title),
tline=None, rline=' ', lline=' ',
trcorner=' ', tlcorner=' ', blcorner='', brcorner='')
header = AttrWrap(header, 'header')
# Body frame, containing the input and the OSK widget
input = Text([('input text', ''), ('prompt', ASCII_BLOCK)])
if input_value != '':
input.set_text([('input text', input_value), ('prompt', ASCII_BLOCK)])
self.input = input
Key = self.add_osk_key # alias the key creation function
osk = Pile([
# 1st keyboard row
WrappableColumns([
(1, Text(" ")),
(3, Key('`', shifted='~')),
(3, Key('1', shifted='!')),
(3, Key('2', shifted='@')),
(3, Key('3', shifted='#')),
(3, Key('4', shifted='$')),
(3, Key('5', shifted='%')),
(3, Key('6', shifted='^')),
(3, Key('7', shifted='&')),
(3, Key('8', shifted='*')),
(3, Key('9', shifted='(')),
(3, Key('0', shifted=')')),
(3, Key('-', shifted='_')),
(3, Key('=', shifted='+')),
(1, Text(" ")),
], 0),
Divider(),
# 2nd keyboard row
WrappableColumns([
(2, Text(" ")),
(3, Key('q')),
(3, Key('w')),
(3, Key('e')),
(3, Key('r')),
(3, Key('t')),
(3, Key('y')),
(3, Key('u')),
(3, Key('i')),
(3, Key('o')),
(3, Key('p')),
(3, Key('[', shifted='{')),
(3, Key(']', shifted='}')),
(3, Key('\\', shifted='|')),
], 0),
Divider(),
# 3rd keyboard row
WrappableColumns([
(3, Text(" ")),
(3, Key('a')),
(3, Key('s')),
(3, Key('d')),
(3, Key('f')),
(3, Key('g')),
(3, Key('h')),
(3, Key('j')),
(3, Key('k')),
(3, Key('l')),
(3, Key(';', shifted=':')),
(3, Key('\'', shifted='"')),
], 0),
Divider(),
# 4th keyboard row
WrappableColumns([
(4, Text(" ")),
(3, Key('z')),
(3, Key('x')),
(3, Key('c')),
(3, Key('v')),
(3, Key('b')),
(3, Key('n')),
(3, Key('m')),
(3, Key(',', shifted='<')),
(3, Key('.', shifted='>')),
(3, Key('/', shifted='?'))
], 0),
Divider(),
# 5th (last) keyboard row
WrappableColumns([
(1, Text(" ")),
(9, Key('↑ Shift', shifted='↑ SHIFT', callback=self.shift_key_press)),
(2, Text(" ")),
(15, Key('Space', value=' ', shifted=' ')),
(2, Text(" ")),
(10, Key('Delete ←', callback=self.bksp_key_press)),
], 0),
Divider()
])
if self.small_display:
# small displays: remove last divider line
osk.contents.pop(len(osk.contents) - 1)
osk = Padding(osk, 'center', 40)
# setup the text input and the buttons
input=AttrWrap(LineBox(input), 'input')
input = Padding(AttrWrap(input, 'input text'), 'center', ('relative', 80), min_width=30)
ok_btn = self.setup_button("OK", self.button_press, exitcode=0)
cancel_btn = self.setup_button("Cancel", self.button_press, exitcode=1)
# setup the main OSK area, depending on the screen size
if self.small_display:
body = Pile([
Text(f'{input_title}', align='center'),
input,
Divider(),
osk,
Divider(),
GridFlow([ok_btn, cancel_btn], 10, 2, 0, 'center'),
Divider()
])
else:
body = Pile([
Divider(), input,
Divider(), osk,
LineBox(
GridFlow([ok_btn, cancel_btn], 10, 2, 0, 'center'),
bline=None, lline=None, rline=None, tlcorner='', trcorner='')
])
body = LineBox(body, f'{input_title}')
body = AttrWrap(body, 'body') # Style the main OSK area
# wrap and align the main OSK in the frame
body = Padding(body, 'center', 55, min_width=42)
body = Filler(body, 'middle')
body = AttrWrap(body, 'bg') # Style the body containing the OSK
frame = Frame(body, header=header, focus_part='body')
return frame
def setup_button(self, label, callback, exitcode=None):
"""
Creates a button and applies the styling
"""
button = CenteredButton(('label', label), callback, delimiters=True)
button.exitcode = exitcode
button = AttrMap(button, {None: 'button'}, {None: 'selected', 'label': 'label selected'})
return button
def setup_popup(self, title):
"""
Overlays a dialog box on top of the working view using a Frame
"""
# Header
if self.small_display:
header = Padding(Text(title, align='center'))
else:
header = LineBox(Text(title),
tline=None, rline=' ', lline=' ',
trcorner=' ', tlcorner=' ', blcorner='', brcorner='')
header = AttrWrap(header, 'header')
# Body
error_text = Text("", align='center')
# register the Text widget with the application, so we can change it
self._error = error_text
error_text = AttrWrap(error_text, 'error')
body = Pile([
Divider(), error_text,
Divider(),
LineBox(
GridFlow([self.setup_button("Dismiss", self.close_popup)],
12, 2, 0, 'center'),
bline=None, lline=None, rline=None, tlcorner='', trcorner='')
])
body = LineBox(body)
body = AttrWrap(body, 'body')
# on small displays let the popup fill the screen (horizontal)
if self.small_display:
body = Padding(Filler(body, 'middle'), 'center')
else:
body = Padding(Filler(body, 'middle'), 'center', ('relative', 50))
body = AttrWrap(body, 'bg')
# Main dialog widget
dialog = Frame(
body,
header=header,
focus_part='body'
)
return dialog
def close_popup(self, widget=None):
self.loop.widget = self.frame
def open_popup(self):
self.loop.widget = self.pop_up
def set_shifted(self, state):
self._shift = state
for b in self.keys:
b.shift(state)
def get_shifted(self):
return self._shift
# create a class property for the shifted state
shifted = property(get_shifted, set_shifted, "The Shift key state")
def set_error_text(self, message):
"""
Sets the error message displayed by 'pop_up'
"""
self._error.set_text(message)
def shift_key_press(self, key=None):
"""
Toggle the Shift key, update display of all printable keys
"""
self.shifted = not self.shifted
def button_press(self, btn):
txt = self.input.get_text()[0].rstrip(ASCII_BLOCK)
# check the input string length when OK is asking to exit
if len(txt) < self._min_chars and btn.exitcode == 0:
self.set_error_text(f"{self._input_title} must have at least {self._min_chars} characters")
self.open_popup()
return
raise ViewExit(btn.exitcode)
def bksp_key_press(self, key=None):
"""
Handle the pressing of the erase key
Remove one char from the end of the text input
"""
txt = self.input.get_text()[0].rstrip(ASCII_BLOCK)
if len(txt) > 0:
txt = txt[:-1]
self.input.set_text([('input text', txt), ('prompt', ASCII_BLOCK)])
def def_key_press(self, key):
"""
Default OSK key press handler, it adds the pressed key to the text input
"""
_inner = key.get_value(self.shifted)
if _inner is None:
return
# remove the final block from the input control and append the value
txt = self.input.get_text()[0].rstrip(ASCII_BLOCK)
self.input.set_text([('input text', txt + _inner), ('prompt', ASCII_BLOCK)])
# when keyboard is shifted, toggle the shift key after a key press
if self.shifted:
self.shift_key_press()
def add_osk_key(self, key, value=None, shifted=None, callback=None):
"""
Method to create a KeyButton with the primary/secondary values and the callback handler
"""
if callback is None:
callback = self.def_key_press
btn = KeyButton(key, primary=value, secondary=shifted, on_press=callback)
# store the key internally, so we can shift it when needed
self.keys.append(btn)
self.def_keys.append(btn.get_value(False))
self.def_keys.append(btn.get_value(True))
return AttrWrap(btn, None, 'focus key')
def unhandled_key(self, key):
"""
Keyboard input handling
"""
# handle the normal key press
if len(key) == 1 and ord(key) in range(32, 127):
txt = self.input.get_text()[0].rstrip(ASCII_BLOCK)
self.input.set_text([('input text', txt + key), ('prompt', ASCII_BLOCK)])
return
if key == 'backspace':
self.bksp_key_press()
# handle Esccape:
# - close the error dialog, if in view, and return to main form
# - exit application if on main form
if str(key) in ('esc'):
if self.loop.widget == self.pop_up:
self.close_popup()
else:
raise urwid.ExitMainLoop()
# unhandled, pass it on
return key
def check_wpa_chars(self):
"""
Debugging method to check whether the OSK provides all valid WPA chars
All allowed WPA chars(https://en.wikipedia.org/wiki/Wi-Fi_Protected_Access#cite_note-21):
Each character in the passphrase must have an encoding in the range of 32 to 126 (decimal), inclusive
"""
wpa_chars = []
for i in range(32, 127):
wpa_chars.append(i)
print(f' All allowed WPA password characters:\n {[chr(k) for k in wpa_chars]}')
missing = False
for k in wpa_chars:
if chr(k) not in self.def_keys:
print(f' {chr(k)} is not provided !')
missing = True
if not missing:
print(f'All chars are handled !')
def on_exit(self, exitcode):
"""
On exit, return an exitcode and - conditionally - the input text
"""
if exitcode != 0:
return exitcode, ''
else:
return exitcode, self.input.get_text()[0].rstrip(ASCII_BLOCK)
def main(self):
"""
Runs the event/display loop for our view
When the OK/Cancel buttons are used, 'ViewExit' will be raised,
otherwise assume the user has used 'Esc' to close the dialog
"""
self.loop = urwid.MainLoop(self.frame, PALETTE, unhandled_input=self.unhandled_key)
try:
self.loop.run()
return self.on_exit(1)
except ViewExit as e:
return self.on_exit(e.args[0])
def parse_arguments(args):
parser = ArgumentParser(description="Reads a string using an On Screen Keyboard")
parser.add_argument('--backtitle', type=str, help='Window title', required=True)
parser.add_argument('--inputbox', type=str, help='Name of the string being captured', required=True)
parser.add_argument(
'--minchars', type=int, nargs='?',
help='Minimum number of characters needed (default: %(default)s)',
default=8)
parser.add_argument('input', type=str, help='Input value', default='', nargs='?')
args = parser.parse_args()
return args.backtitle, args.inputbox, args.minchars, args.input
def main():
backtitle, inputbox, minchars, value = parse_arguments(sys.argv)
# get the terminal size to detect small display
cols, rows = get_terminal_size(0)
osk = OSK(backtitle, inputbox, value, minchars, (cols < SMALL_SCREEN_COLS or rows < SMALL_SCREEN_ROWS))
exitcode, exitstring = osk.main()
# print the input text when returned by the application
if exitstring:
sys.stderr.write(exitstring + "\n")
sys.exit(exitcode)
if __name__ == "__main__":
main()