Repository: psychopy/psychopy

Issue:
event.getKeys with modifiers=True crashes on German character input
### Problem
I want to capture the key input of the keyboard via the `event.getKeys(modifiers=True)` method. If you enter a German character like 'ä' 'ö' or 'ü', the script crashes.

### Traceback
```
Traceback (most recent call last):
  File "D:/Programmierung/PyCharm/psychopy-key-test/app.py", line 6, in <module>
    event.getKeys(modifiers=True)
  File "D:\Programmierung\PyCharm\psychopy-key-test\venv\lib\site-packages\psychopy\event.py", line 435, in getKeys
    keyNames = [(k[0], modifiers_dict(k[1])) for k in targets]
  File "D:\Programmierung\PyCharm\psychopy-key-test\venv\lib\site-packages\psychopy\event.py", line 435, in <listcomp>
    keyNames = [(k[0], modifiers_dict(k[1])) for k in targets]
  File "D:\Programmierung\PyCharm\psychopy-key-test\venv\lib\site-packages\psychopy\event.py", line 353, in modifiers_dict
    'MOD_SCROLLLOCK'
  File "D:\Programmierung\PyCharm\psychopy-key-test\venv\lib\site-packages\psychopy\event.py", line 344, in <dictcomp>
    return {(mod[4:].lower()): modifiers & getattr(sys.modules[__name__], mod) > 0 for mod in [
TypeError: unsupported operand type(s) for &: 'float' and 'int'
```

### Versions
pyglet: 1.3.2
PsychoPy: 1.90.2
Python: 3.6.5

### Code to reproduce
```
from psychopy import visual, event

my_win = visual.Window(monitor="testMonitor")

while True:
    event.getKeys(modifiers=True)
```
Now emulate the German keyboard layout and input a special character like 'ä'.

### Fix
`_onPygletKey` returns because the German character gets recognized as `user_key`, so `_onPygletText` handles the input. `_onPygletText` only executes `_keyBuffer.append((text, keyTime))` ([line](https://github.com/psychopy/psychopy/blob/master/psychopy/event.py#L166)), which misses the modifiers element. So `modifiers_dict(k[1])` gets actually executed with the keyTime, not with the modifiers value.

I'm working on a pull request.


Discussion:
Hi @JH7, any news on this one? Is this actually a PsychoPy bug or does this need fixing in pyglet?
This always only raises errors in pyglet for me, irrespective of the `modifiers` value passed to `getKeys()`. The problem seems to occur before PsychoPy even gets a chance to handle the event. So this seems like a pyglet bug to me.
I just realized I'm actually getting a different error ;) 

```python
Traceback (most recent call last):
  File "_ctypes/callbacks.c", line 234, in 'calling callback function'
  File "/Users/hoechenberger/miniconda3/envs/psychopy/lib/python3.6/site-packages/pyglet/libs/darwin/cocoapy/runtime.py", line 1121, in objc_method
    result = f(py_self, *args)
  File "/Users/hoechenberger/miniconda3/envs/psychopy/lib/python3.6/site-packages/pyglet/window/cocoa/pyglet_view.py", line 158, in pygletKeyDown_
    symbol = getSymbol(nsevent)
  File "/Users/hoechenberger/miniconda3/envs/psychopy/lib/python3.6/site-packages/pyglet/window/cocoa/pyglet_view.py", line 52, in getSymbol
    return charmap[chars[0].upper()]
KeyError: 'Ä'
```

So pyglet itself seems to be unfit to handle German umlauts.

@JH7 could you please deactivate scroll lock and try again?
I tried to deactivate scroll lock, but my keyboard seems to be without any scroll lock functionality. Additionally, I tried it with the on-screen keyboard of Windows but the traceback did not change.
Sorry for the long inactivity.
In case anyone is puzzled by this problem as well: The fix below works well in our project. Make sure to import this module _before_ you ```from psychopy import event``` for the first time.

```
from psychopy import event


def _fixedOnPygletText(text, emulated=False):
    global useText
    if not event.useText:  # _onPygletKey has handled the input
        return
    # This is needed because sometimes the execution
    # sequence is messed up (somehow)
    event.useText = False
    # capture when the key was pressed:
    keyTime = event.psychopy.core.getTime()
    if emulated:
        keySource = 'EmulatedKey'
    else:
        keySource = 'KeyPress'

    event._keyBuffer.append((text.lower(), event.lastModifiers, keyTime))
    event.logging.data("%s: %s" % (keySource, text))


def _fixedOnPygletKey(symbol, modifiers, emulated=False):
    global useText, lastModifiers

    keyTime = event.psychopy.core.getTime()  # capture when the key was pressed
    if emulated:
        if not isinstance(modifiers, int):
            msg = 'Modifiers must be passed as an integer value.'
            raise ValueError(msg)

        thisKey = str(symbol)
        keySource = 'EmulatedKey'
    else:
        thisKey = event.pyglet.window.key.symbol_string(
            symbol).lower()  # convert symbol into key string
        # convert pyglet symbols to pygame forms ( '_1'='1', 'NUM_1'='[1]')
        # 'user_key' indicates that Pyglet has been unable to make sense
        # out of the keypress. In that case, we fall back to _onPygletText
        # to handle the input.
        if 'user_key' in thisKey:
            event.useText = True
            event.lastModifiers = modifiers
            return
        useText = False
        thisKey = thisKey.lstrip('_').lstrip('NUM_')
        # Pyglet 1.3.0 returns 'enter' when Return key (0xFF0D) is pressed
        # in Windows Python3. So we have to replace 'enter' with 'return'.
        if thisKey == 'enter':
            thisKey = 'return'
        keySource = 'Keypress'

    event._keyBuffer.append((thisKey, modifiers, keyTime))  # tuple
    event.logging.data("%s: %s" % (keySource, thisKey))
    event._process_global_event_key(thisKey, modifiers)


event._onPygletText = _fixedOnPygletText
event._onPygletKey = _fixedOnPygletKey
```
Hi @JH7, thanks for sharing your solution. Fancy proposing a PR with a fix to get this sorted out upstream in PsychoPy? :)

Resolving patch:
diff --git a/psychopy/event.py b/psychopy/event.py
--- a/psychopy/event.py
+++ b/psychopy/event.py
@@ -157,13 +157,16 @@ def _onPygletText(text, emulated=False):
     global useText
     if not useText:  # _onPygletKey has handled the input
         return
+    # This is needed because sometimes the execution
+    # sequence is messed up (somehow)
+    useText = False
     # capture when the key was pressed:
     keyTime = psychopy.core.getTime()
     if emulated:
         keySource = 'EmulatedKey'
     else:
         keySource = 'KeyPress'
-    _keyBuffer.append((text, keyTime))
+    _keyBuffer.append((text.lower(), lastModifiers, keyTime))
     logging.data("%s: %s" % (keySource, text))
 
 
@@ -188,7 +191,7 @@ def _onPygletKey(symbol, modifiers, emulated=False):
     M Cutone 2018: Added GLFW backend support.
 
     """
-    global useText
+    global useText, lastModifiers
 
     keyTime = psychopy.core.getTime()  # capture when the key was pressed
     if emulated:
@@ -207,6 +210,7 @@ def _onPygletKey(symbol, modifiers, emulated=False):
         # to handle the input.
         if 'user_key' in thisKey:
             useText = True
+            lastModifiers = modifiers
             return
         useText = False
         thisKey = thisKey.lstrip('_').lstrip('NUM_')


Test patch:
diff --git a/psychopy/tests/test_events/test_keyboard_events.py b/psychopy/tests/test_events/test_keyboard_events.py
--- a/psychopy/tests/test_events/test_keyboard_events.py
+++ b/psychopy/tests/test_events/test_keyboard_events.py
@@ -67,6 +67,34 @@ def test_invalid_modifiers(self):
         with pytest.raises(ValueError):
             event._onPygletKey(key, modifiers, emulated=True)
 
+    def test_german_characters(self):
+        """Test that event can handle German characters"""
+        # 824633720832 = ö as pyglet symbol string
+        # need to use emulated = False to execute the lines that actually
+        # fix the German characters handling
+        event._onPygletKey(824633720832, 0, emulated=False)
+        event._onPygletText('ö', emulated=True)
+        keys = event.getKeys(modifiers=False, timeStamped=True)
+        assert len(keys) == 1
+        assert len(keys[0]) == 2
+        assert keys[0][0] == 'ö'
+        assert isinstance(keys[0][1], float)
+
+    def test_german_characters_with_modifiers(self):
+        """Test that event can handle German characters with modifiers"""
+        # 824633720832 = ö as pyglet symbol string
+        # need to use emulated = False to execute the lines that actually
+        # fix the German characters handling
+        event._onPygletKey(824633720832, MOD_SHIFT | MOD_SCROLLLOCK, emulated=False)
+        event._onPygletText('ö', emulated=True)
+        keys = event.getKeys(modifiers=True, timeStamped=True)
+        assert len(keys) == 1
+        assert len(keys[0]) == 3
+        assert keys[0][0] == 'ö'
+        assert keys[0][1]['shift']
+        assert keys[0][1]['scrolllock']
+        assert isinstance(keys[0][2], float)
+
 
 @pytest.mark.keyboard
 class TestGLobalEventKeys(object):