Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

interrupting embedded pygame in tkinter skips KEYUP events and thinks the key is still pressed

In this code I have holding 'shift' turn the screen green. If the pygame focus is interrupted while holding 'shift', it skips the KEYUP events, and pygame continues to think that 'shift' is being held. Simulating KEYUP events does not work. The only fix I've found is to press and release 'shift' manually, but I do not want the user to have to do that.

To demonstrate, run the code and press and hold 'Shift', and while holding, press 'Enter' to open a dialog. Then release 'Shift', and then exit the dialog. The green screen will remain, even though 'Shift' is not being held.

If you run the code again after turning 'embedding_pygame_and_showing_the_bug' to False, you'll see that the KEYUP events are not skipped.

import tkinter as tk
import pygame
import os
from tkinter.simpledialog import askstring

root = tk.Tk()
root.geometry("200x100")

embedding_pygame_and_showing_the_bug = True
if embedding_pygame_and_showing_the_bug:
    embed_frame = tk.Frame(root)
    embed_frame.pack(fill='both', expand=True)
    os.environ['SDL_WINDOWID'] = str(embed_frame.winfo_id())
    os.environ['SDL_VIDEODRIVER'] = 'windib'

pygame.init()
screen = pygame.display.set_mode((200, 100))
pygame.event.set_blocked([pygame.MOUSEMOTION, pygame.ACTIVEEVENT])
while True:
    root.update()
    for event in pygame.event.get():
        print(event)
        screen.fill((255, 255, 255))
        if pygame.key.get_mods() & pygame.KMOD_SHIFT:
            screen.fill((50, 205, 50))

        if event.type == pygame.KEYDOWN and event.key == pygame.K_RETURN:
            askstring(' ', ' ', parent=root)
            # simulating a KEYUP does not convince pygame think that shift is not being pressed
            pygame.event.post(pygame.event.Event(pygame.KEYUP, {'key': 304, 'mod': 0, 'scancode': 42, 'window': None}))
            pygame.event.pump()

        pygame.display.flip()
like image 947
nda Avatar asked Dec 31 '22 04:12

nda


1 Answers

Explanation

Pygame's window

What you've already noticed is that a KEYUP event is sent whenever pygame's window loses focus. The reason for this is that pygame's key events are, for the most part, wrappers of SDL's key events (SDL is a library written in C with low level access to many different components, one of which is graphics hardware) and if you look at SDL_KeyboardEvent's data fields, you can see that one data field is called windowID:

The windowID data field is responsible for holding window ID of the window from which it's grabbing keyboard inputs - only while the window is in focus as otherwise the window has no information over the keyboard inputs. As a protection measure, SDL's window automatically sends an artificial KEYUP event whenever the window specified in windowID loses focus (which in turn makes pygame send the KEYUP event as well). Another thing to note is that the OS sends multiple KEYDOWN events to a window whenever a key is held, but SDL automatically ignores every KEYDOWN event other than the first one.

Tkinter's window

Tkinter's window, as any other window, also gets keyboard inputs from the OS - but raw inputs. That's why, when you hold a key for a while, tkinter's window shows all the KeyPress events it gets from the OS. When a tkinter's window loses focus, it doesn't send any KeyRelease event as it hasn't gotten any from the OS (unlike SDL's window, where the KEYUP event is generated artificially).

Pygame inside of Tkinter's window

When pygame uses tkinter's window, it can't catch the OS's keyboard inputs - only the keyboard events from tkinter. The reason for this is the following line of code:

os.environ['SDL_WINDOWID'] = str(embed_frame.winfo_id())

SDL_KeyboardEvent now uses embed_frame's windowID. That means that all the keyboard events pygame catches will be from tkinter. This is why pygame inside of tkinter doesn't have an extra KEYUP event when the tkinter's window loses focus, where as it has the KEYUP event when it uses it's own window.

Solution

Unless you're willing to edit and compile pygame, tkinter or SDL, there is no way to solve this using pygame's default event queue. But you can still use a custom event handler or write your own and work around this issue.

For this example, just having a key listener is enough (solution uses pynput):

import tkinter as tk
import pygame
import os
from tkinter.simpledialog import askstring
from pynput import keyboard


on_enter = False


def on_press(key):
    # Uses global 'on_enter' variable
    global on_enter

    # If key's name is a single letter ('a', '1', etc.) then 'key.char' is used,
    # otherwise ('shift', 'enter', etc.) 'key.name' is used
    try:
        key_ = key.char
    except AttributeError:
        key_ = key.name

    # When 'shift' is down - set color to dark* green
    if key_ == 'shift':
        screen.fill((50, 205, 50))
    # When 'enter' is down - set color to white, run 'askstring'
    elif key_ == 'enter':
        screen.fill((255, 255, 255))
        on_enter = True

def on_release(key):
    # If key's name is a single letter ('a', '1', etc.) then 'key.char' is used,
    # otherwise ('shift', 'enter', etc.) 'key.name' is used
    try:
        key_ = key.char
    except AttributeError:
        key_ = key.name

    # When 'shift' is up - set color to white
    if key_ == 'shift':
        screen.fill((255, 255, 255))


root = tk.Tk()
root.geometry("200x100")

embedding_pygame_and_showing_the_feature = True
if embedding_pygame_and_showing_the_feature:
    embed_frame = tk.Frame(root)
    embed_frame.pack(fill='both', expand=True)
    os.environ['SDL_WINDOWID'] = str(embed_frame.winfo_id())
    os.environ['SDL_VIDEODRIVER'] = 'windib'

pygame.init()
screen = pygame.display.set_mode((200, 100))
screen.fill((255, 255, 255))

# Runs 'on_press' when it detects key down,
# runs 'on_release' when it detects key up
listener = keyboard.Listener(on_press=on_press, on_release=on_release)
listener.start()

while True:
    # on_enter == True only when 'enter' is down
    if on_enter:
        # This display flip is necessary as keyboard.Listener runs
        # in a separate thread - 'on_enter' can change at any point
        # in the while-loop
        pygame.display.flip()

        askstring(' ', ' ', parent=root)
        on_enter = False

    root.update()
    pygame.display.flip()

like image 138
Nemanja Mirić Avatar answered Jan 03 '23 04:01

Nemanja Mirić