3
\$\begingroup\$

This is something I made as an input display resembling NohBoard because I need to make a video for a video game, but it's on Linux which doesn't have any good input display programs. One thing I'm worried about is the way I'm making the scroll wheel flash last for exactly three frames in a 60 FPS recording. I checked how many frames it lasted by checking from a recording, and it seemed to be working fine, but I'm still not 100% sure if it's the best way to achieve making it last for exactly three frames in a 60 FPS recording.

import pygame
import pynput
import threading

def main():
    
    lock_for_dict = threading.Lock()
    lock_for_scroll = threading.Lock()
    
    pygame.init()
    
    win = pygame.display.set_mode((343, 229))
    pygame.display.set_caption("test")
    font = pygame.font.Font(pygame.font.get_default_font(), 15)
    down_font = pygame.font.Font(pygame.font.get_default_font(), 14)
    
    # {key_or_mouse_button: (rect_location_and_size, (black_key_text, white_key_text), key_text_location)}
    # 0 and 1 are left mouse and right mouse
    shown_keys = {
        0: ((250,94,41,41), (font.render("LM", True, (0,0,0)), font.render("LM", True, (255,255,255))), (260,106)),
        1: ((292,94,41,41), (font.render("RM", True, (0,0,0)), font.render("RM", True, (255,255,255))), (300,106)),
        "a": ((72,94,41,41), (font.render("A", True, (0,0,0)), font.render("A", True, (255,255,255))), (87,106)),
        "w": ((114,52,41,41), (font.render("W", True, (0,0,0)), font.render("W", True, (255,255,255))), (127,64)),
        "s": ((114,94,41,41), (font.render("S", True, (0,0,0)), font.render("S", True, (255,255,255))), (129,106)),
        "d": ((156,94,41,41), (font.render("D", True, (0,0,0)), font.render("D", True, (255,255,255))), (171,106)),
        "q": ((72,52,41,41), (font.render("Q", True, (0,0,0)), font.render("Q", True, (255,255,255))), (87,64)),
        "e": ((156,52,41,41), (font.render("E", True, (0,0,0)), font.render("E", True, (255,255,255))), (171,64)),
        "r": ((198,52,41,41), (font.render("R", True, (0,0,0)), font.render("R", True, (255,255,255))), (213,64)),
        "f4": ((198,10,41,41), (font.render("F4", True, (0,0,0)), font.render("F4", True, (255,255,255))), (210,22)),
        "tab": ((10,52,61,41), (font.render("TAB", True, (0,0,0)), font.render("TAB", True, (255,255,255))), (25,64)),
        "alt": ((72,178,61,41), (font.render("ALT", True, (0,0,0)), font.render("ALT", True, (255,255,255))), (87,190)),
        "shift": ((10,136,81,41), (font.render("SHIFT", True, (0,0,0)), font.render("SHIFT", True, (255,255,255))), (28,148)),
        "ctrl": ((10,178,61,41), (font.render("CTRL", True, (0,0,0)), font.render("CTRL", True, (255,255,255))), (20,190)),
        "enter": ((134,178,81,41), (font.render("ENTER", True, (0,0,0)), font.render("ENTER", True, (255,255,255))), (148,190)),
        "down": ((216,178,41,41), (down_font.render("down", True, (0,0,0)), down_font.render("down", True, (255,255,255))), (217,190))
    }
    held_or_released = dict()
    
    for v in shown_keys.values():
        pygame.draw.rect(win, (0,0,0), v[0])
        win.blit(v[1][1], v[2])
    
    scroll_up_text = (font.render("SU", True, (0,0,0)), font.render("SU", True, (255,255,255)))
    scroll_down_text = (font.render("SD", True, (0,0,0)), font.render("SD", True, (255,255,255)))
    scroll_time_remaining = [None, 0, 0] # [None, up, down]
    
    def on_scroll(
        x,
        y,
        dx,
        dy,
        lock_for_scroll_=lock_for_scroll,
        scroll_time_remaining_=scroll_time_remaining
        ): # dy is 1 when scrolling up and -1 when scrolling down
        with lock_for_scroll_:
            scroll_time_remaining_[dy] = 42 # midpoint between 2 frames and 3 frames at 60 FPS
    
    def on_click(
        x,
        y,
        button,
        pressed,
        lock_for_dict_=lock_for_dict,
        held_or_released_=held_or_released
        ):
        with lock_for_dict_:
            held_or_released_[button.name == "right"] = pressed
    
    def on_press(
        key,
        lock_for_dict_=lock_for_dict,
        held_or_released_=held_or_released,
        shown_keys_=shown_keys,
        hasattr_=hasattr,
        str_lower=str.lower
        ): # this gets called repeatedly if the key is held down
        if hasattr_(key, "char") and (k := key.char) is not None:
            k = str_lower(k)
            if k not in held_or_released_ and k in shown_keys_:
                with lock_for_dict_:
                    held_or_released_[k] = True
        elif hasattr_(key, "name") and (k := key.name) is not None:
            if k not in held_or_released_ and k in shown_keys_:
                with lock_for_dict_:
                    held_or_released_[k] = True
        else:
            with lock_for_dict_:
                held_or_released_[("tab", "alt")[key.vk % 2]] = True
    
    def on_release(
        key,
        lock_for_dict_=lock_for_dict,
        held_or_released_=held_or_released,
        shown_keys_=shown_keys,
        hasattr_=hasattr,
        str_lower=str.lower
        ):
        if hasattr_(key, "char") and (k := key.char) is not None:
            k = str_lower(k)
            if k in shown_keys_:
                with lock_for_dict_:
                    held_or_released_[k] = False
        elif hasattr_(key, "name") and (k := key.name) is not None:
            if k in shown_keys_:
                with lock_for_dict_:
                    held_or_released_[k] = False
        else:
            with lock_for_dict_:
                held_or_released_[("tab", "alt")[key.vk % 2]] = False
    
    key_listener = pynput.keyboard.Listener(on_press=on_press, on_release=on_release)
    mouse_listener = pynput.mouse.Listener(on_scroll=on_scroll, on_click=on_click)
    key_listener.daemon = True
    mouse_listener.daemon = True
    key_listener.start()
    mouse_listener.start()
    
    pygame_time_wait = pygame.time.wait
    pygame_display_update = pygame.display.update
    pygame_draw_rect = pygame.draw.rect
    win_blit = win.blit
    held_or_released_items = held_or_released.items
    held_or_released_clear = held_or_released.clear
    pygame_QUIT = pygame.QUIT
    pygame_event_get = pygame.event.get
    any_ = any
    
    while not any_(event.type == pygame_QUIT for event in pygame_event_get()):
        
        waited_time = pygame_time_wait(1)
        
        pygame_display_update()
        
        with lock_for_scroll:
            if scroll_time_remaining[1] >= 0:
                scroll_time_remaining[1] -= waited_time
                pygame_draw_rect(win, ((0,0,0), (255,255,255))[scroll_time_remaining[1] >= 0], (271,52,41,41))
                win_blit(scroll_up_text[scroll_time_remaining[1] < 0], (281, 64))
            if scroll_time_remaining[2] >= 0:
                scroll_time_remaining[2] -= waited_time
                pygame_draw_rect(win, ((0,0,0), (255,255,255))[scroll_time_remaining[2] >= 0], (271,136,41,41))
                win_blit(scroll_down_text[scroll_time_remaining[2] < 0], (281, 148))
        
        with lock_for_dict:
            for k, v in held_or_released_items():
                args = shown_keys[k]
                pygame_draw_rect(win, ((0,0,0), (255,255,255))[v], args[0])
                win_blit(args[1][not v], args[2])
            held_or_released_clear()
    
    pygame.quit()

main()
\$\endgroup\$
2
\$\begingroup\$

Linux which doesn't have any good input display programs

I deeply doubt this, but moving on:

  • You have a one-outer-function program that leans heavily on closures. This is not a good way to represent state, and is untestable. There are better ways to pass around state. In my recommendation I show two based on context: either bind to some separated functions with partial, or use a class.
  • Add some PEP484 type hints.
  • Don't use anonymous tuples; use named tuples, dataclasses or normal classes for your shown keys.
  • Remove the redundancy from shown_keys - you rewrite the constants for black and white several times, as well as your antialias setting, etc.
  • Perhaps do not represent held_or_released as a dictionary of booleans, but instead as a set. Membership in the set indicates held.
  • Centralize the repeated code chunk to draw a character.
  • Your scroll_time_remaining indexing strategy is downright nasty. A list of two elements with re-mapped indices, or perhaps a dictionary, will be less hacky. Among other side-effects of your current design decision, the fact that you have a None in the mix means you require Optional where you shouldn't.
  • Why are you aliasing a bunch of built-ins (hasattr, lower, any)? Don't do this. pygame_time_wait = pygame.time.wait is equally unhelpful.
  • Don't held_or_released_clear() at all. This looks to be a hack to work around the fact that you're ignoring the pressed parameter to on_click.
  • held_or_released_[("tab", "alt")[key.vk % 2]] is nasty and probably several kinds of fragile. If you know the virtual codes for tab and alt, just use the codes directly. In my sample code I've skipped over this and the only combination I've found not to work is shift+tab.
  • Instead of hasattr, you can check for the actual instance type using isinstance, and reflect this decision in a Union parameter.
  • You don't have any animation, so you should altogether discard the idea of FPS and event.get, replacing it with event.wait. For your scrolling timeout logic you can use a timer instead of a polling loop. I have not dug into this in my sample code.
  • Add a __main__ guard.

Suggested

from functools import partial
from typing import Optional, List, Union, Tuple, Dict, Set, Callable

import pygame
import pynput
import threading

from pygame.font import Font
from pynput.keyboard import Key, KeyCode
from pynput.mouse import Button

BLACK = 0, 0, 0
WHITE = 255, 255, 255
ANTIALIAS = True


KeyType = Union[str, bool]


class ShownKey:
    def __init__(
        self,
        key: KeyType,
        name: str,
        font: Font,
        rect: Tuple[int, int, int, int],
        text_pos: Tuple[int, int],
    ):
        self.key, self.name, self.rect, self.text_pos = key, name, rect, text_pos
        self.black_text: pygame.Surface = font.render(name, ANTIALIAS, BLACK)
        self.white_text: pygame.Surface = font.render(name, ANTIALIAS, WHITE)

    def draw(self, win: pygame.Surface, held: bool) -> None:
        if held:
            background = WHITE
            text = self.black_text
        else:
            background = BLACK
            text = self.white_text
        pygame.draw.rect(win, background, self.rect)
        win.blit(text, self.text_pos)


ShownKeyDict = Dict[KeyType, ShownKey]
HeldSet = Set[KeyType]


def on_scroll(
    x: int, y: int, dx: int, dy: int,
    lock_for_scroll: threading.Lock,
    scroll_time_remaining: List[Optional[int]],
) -> None:
    # dy is 1 when scrolling up and -1 when scrolling down
    with lock_for_scroll:
        scroll_time_remaining[dy] = 42  # midpoint between 2 frames and 3 frames at 60 FPS


def on_click(
    x: int, y: int, button: Button, pressed: bool,
    lock_for_dict: threading.Lock,
    held_or_released: HeldSet,
) -> None:
    with lock_for_dict:
        val = button.name == "right"
        if pressed:
            held_or_released.add(val)
        else:
            held_or_released.discard(val)


def on_press_or_release(
    key: Union[Key, KeyCode],
    lock_for_dict: threading.Lock,
    is_shown: Callable[[KeyType], bool],
    update_held: Callable[[KeyType], None],
) -> None:
    # this gets called repeatedly if the key is held down
    if isinstance(key, KeyCode):
        if key.char is None:
            # Deal with virtual keys here
            return
        k = key.char.lower()
    elif isinstance(key, Key):
        k = key.name
    else:
        raise NotImplementedError()

    if is_shown(k):
        with lock_for_dict:
            update_held(k)


def main():
    lock_for_dict = threading.Lock()
    lock_for_scroll = threading.Lock()

    pygame.init()

    win = pygame.display.set_mode((343, 229))
    pygame.display.set_caption("test")
    font = pygame.font.Font(pygame.font.get_default_font(), 15)
    down_font = pygame.font.Font(pygame.font.get_default_font(), 14)

    # {key_or_mouse_button: (rect_location_and_size, (black_key_text, white_key_text), key_text_location)}
    # 0 and 1 are left mouse and right mouse
    LEFT_MOUSE = False
    RIGHT_MOUSE = True

    shown_keys: ShownKeyDict = {
        key: ShownKey(key, name, font, rect, text_pos)
        for key, rect, name, text_pos in (
            (LEFT_MOUSE, (250, 94, 41, 41), "LM", (260, 106)),
            (RIGHT_MOUSE, (292, 94, 41, 41), "RM", (300, 106)),
            ("a", (72, 94, 41, 41), "A", (87, 106)),
            ("w", (114, 52, 41, 41), "W", (127, 64)),
            ("s", (114, 94, 41, 41), "S", (129, 106)),
            ("d", (156, 94, 41, 41), "D", (171, 106)),
            ("q", (72, 52, 41, 41), "Q", (87, 64)),
            ("e", (156, 52, 41, 41), "E", (171, 64)),
            ("r", (198, 52, 41, 41), "R", (213, 64)),
            ("f4", (198, 10, 41, 41), "F4", (210, 22)),
            ("tab", (10, 52, 61, 41), "TAB", (25, 64)),
            ("alt", (72, 178, 61, 41), "ALT", (87, 190)),
            ("shift", (10, 136, 81, 41), "SHIFT", (28, 148)),
            ("ctrl", (10, 178, 61, 41), "CTRL", (20, 190)),
            ("enter", (134, 178, 81, 41), "ENTER", (148, 190)),
        )
    }
    shown_keys['down'] = ShownKey('down', 'down', down_font, (216, 178, 41, 41), (217, 190))
    all_keys = set(shown_keys.keys())
    held_or_released: HeldSet = set()

    for key in shown_keys.values():
        key.draw(win, held=False)

    scroll_up_text = (font.render("SU", ANTIALIAS, BLACK), font.render("SU", ANTIALIAS, WHITE))
    scroll_down_text = (font.render("SD", ANTIALIAS, BLACK), font.render("SD", ANTIALIAS, WHITE))
    scroll_time_remaining = [None, 0, 0]  # [None, up, down]

    key_listener = pynput.keyboard.Listener(
        on_press=partial(
            on_press_or_release, lock_for_dict=lock_for_dict,
            is_shown=shown_keys.__contains__, update_held=held_or_released.add,
        ),
        on_release=partial(
            on_press_or_release, lock_for_dict=lock_for_dict,
            is_shown=shown_keys.__contains__, update_held=held_or_released.discard,
        ),
    )
    mouse_listener = pynput.mouse.Listener(
        on_scroll=partial(on_scroll, lock_for_scroll=lock_for_scroll, scroll_time_remaining=scroll_time_remaining),
        on_click=partial(on_click, lock_for_dict=lock_for_dict, held_or_released=held_or_released),
    )
    key_listener.daemon = True
    mouse_listener.daemon = True
    key_listener.start()
    mouse_listener.start()

    while not any(event.type == pygame.QUIT for event in pygame.event.get()):

        waited_time = pygame.time.wait(1)

        pygame.display.update()

        with lock_for_scroll:
            if scroll_time_remaining[1] >= 0:
                scroll_time_remaining[1] -= waited_time
                pygame.draw.rect(win, (BLACK, WHITE)[scroll_time_remaining[1] >= 0], (271, 52, 41, 41))
                win.blit(scroll_up_text[scroll_time_remaining[1] < 0], (281, 64))
            if scroll_time_remaining[2] >= 0:
                scroll_time_remaining[2] -= waited_time
                pygame.draw.rect(win, (BLACK, WHITE)[scroll_time_remaining[2] >= 0], (271, 136, 41, 41))
                win.blit(scroll_down_text[scroll_time_remaining[2] < 0], (281, 148))

        with lock_for_dict:
            for key in held_or_released:
                shown_keys[key].draw(win, held=True)
            for key in all_keys - held_or_released:
                shown_keys[key].draw(win, held=False)
            # held_or_released.clear()

    pygame.quit()


if __name__ == '__main__':
    main()
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.