spyder-ide / qtpy

Provides an uniform layer to support PyQt5, PySide2, PyQt6, PySide6 with a single codebase
MIT License
954 stars 151 forks source link

`Qt.MouseButtons` and `Qt.Key` potential unification for Qt5 vs Qt6 #489

Open th3w1zard1 opened 1 month ago

th3w1zard1 commented 1 month ago

In PyQt5, it's common to use a Qt.Key like this:

    def _modifierKeyFix(self, e: QKeyEvent, parentFuncName: str):
        """Fix for when keys get stuck in the _keysDown set, call this sparingly."""
        current_modifiers = e.modifiers()

        for key in MODIFIER_KEYS:
            bitcheck = int(current_modifiers & key)  # Explicitly convert to int for clarity in logs
            if bitcheck == 0 and key in self._keysDown:
                RobustRootLogger().debug(f"Inferred Release ({parentFuncName}): {key} Key")
                self._keysDown.discard(key)

However that fails in PyQt6 even with qtpy, with the following error:

TypeError: unsupported operand type(s) for &: 'KeyboardModifier' and 'Key'

Another example, let's say I want to do similar with Qt.MouseButton:

    def _contextMenuRightMouseButtonFix(self, e: QMouseEvent, parentFuncName: str):
        """Fix for when the context menu causes focusloss events that cause the button to get stuck, call this sparingly."""
        current_buttons = e.buttons()
        # Ensure compatibility with both PyQt5 and PyQt6
        if not isinstance(current_buttons, int):
            current_buttons = int(current_buttons)
        rightbitcheck = int(current_buttons & Qt.RightButton)  # Explicitly convert to int for clarity in logs
        if rightbitcheck == 0 and Qt.RightButton in self._mouseDown:
            RobustRootLogger().debug(f"Inferred Release ({parentFuncName}): Right Button")
            self._mouseDown.discard(Qt.RightButton)

Raises this error:

"C:\GitHub\PyKotor\Tools\HolocronToolset\src\toolset\gui\widgets\renderer\walkmesh.py", line 886, in _contextMenuRightMouseButtonFix
    current_buttons = int(current_buttons)
TypeError: int() argument must be a string, a bytes-like object or a number, not 'MouseButton'

This code works fine:

    def _contextMenuRightMouseButtonFix(self, e: QMouseEvent, parentFuncName: str):
        """Fix for when the context menu causes focusloss events that cause the button to get stuck, call this sparingly."""
        current_buttons = e.buttons()
        right_button_value = Qt.RightButton

        if isinstance(current_buttons, Qt.MouseButton):
            # PyQt6: current_buttons is a MouseButton enum, use .value to get the int representation
            rightbitcheck = current_buttons.value & right_button_value.value
        else:
            # PyQt5: current_buttons is already an int
            rightbitcheck = current_buttons & right_button_value
        if rightbitcheck == 0 and Qt.RightButton in self._mouseDown:
            RobustRootLogger().debug(f"Inferred Release ({parentFuncName}): Right Button")
            self._mouseDown.discard(Qt.RightButton)

An easy way to unify this, imo, is to check API_NAME in ("PyQt6", "PySide6") and if so just slap on the __int__ and __and__ like this in qtpy/QtCore.py:

# Mirror https://github.com/spyder-ide/qtpy/pull/393
if PYQT5 or PYSIDE2:
    QLibraryInfo.path = QLibraryInfo.location
    QLibraryInfo.LibraryPath = QLibraryInfo.LibraryLocation
if PYQT6 or PYSIDE6:
    QLibraryInfo.location = QLibraryInfo.path
    QLibraryInfo.LibraryLocation = QLibraryInfo.LibraryPath
    QtCore.Qt.MouseButton.__int__ = lambda self: self.value
    QtCore.Qt.MouseButton.__and__= lambda self, other: self.value & other
    QtCore.Qt.Key.__int__ = lambda self: self.value
    QtCore.Qt.Key.__and__= lambda self, other: self.value & other
ccordoba12 commented 1 month ago

Hey @th3w1zard1, thanks for reporting. Your suggestion sounds reasonable to me.

@dalthviz, what do you think?

th3w1zard1 commented 1 month ago

I got fed up with the nonsense differences between the qt versions, ended up writing this monstrosity which has been tested for the last 3 hours:

from __future__ import annotations

from typing import TYPE_CHECKING, TypeVar

from qtpy import API_NAME, QtCore
from qtpy.QtCore import QUrl
from qtpy.QtGui import QDesktopServices, QKeySequence

from utility.system.path import Path

if TYPE_CHECKING:

    import os

QtKey = QtCore.Qt.Key
QtMouse = QtCore.Qt.MouseButton
T = TypeVar("T")

MODIFIER_KEY_NAMES = {
    QtKey.Key_Control: "CTRL",
    QtKey.Key_Shift: "SHIFT",
    QtKey.Key_Alt: "ALT",
    QtKey.Key_Meta: "META",  # Often corresponds to the Windows or Command key
    QtKey.Key_AltGr: "ALTGR",  # Alt Graph key
    QtKey.Key_CapsLock: "CAPSLOCK",
    QtKey.Key_NumLock: "NUMLOCK",
    QtKey.Key_ScrollLock: "SCROLLLOCK",
}
MODIFIER_KEYNAME_TO_KEY: dict[str, QtKey] = {v: k for k, v in MODIFIER_KEY_NAMES.items()}

MOUSE_BUTTON_NAMES: dict[QtMouse, str] = {
    QtMouse.LeftButton: "LeftButton",
    QtMouse.RightButton: "RightButton",
    QtMouse.MiddleButton: "MiddleButton",
    QtMouse.BackButton: "BackButton",
    QtMouse.ForwardButton: "ForwardButton",
    QtMouse.TaskButton: "TaskButton",
    QtMouse.ExtraButton1: "ExtraButton1",
    QtMouse.ExtraButton2: "ExtraButton2",
    QtMouse.ExtraButton3: "ExtraButton3",
    QtMouse.ExtraButton4: "ExtraButton4",
    QtMouse.ExtraButton5: "ExtraButton5",
    QtMouse.ExtraButton6: "ExtraButton6",
    QtMouse.ExtraButton7: "ExtraButton7",
    QtMouse.ExtraButton8: "ExtraButton8",
    QtMouse.ExtraButton9: "ExtraButton9",
    QtMouse.ExtraButton10: "ExtraButton10",
    QtMouse.ExtraButton11: "ExtraButton11",
    QtMouse.ExtraButton12: "ExtraButton12",
    QtMouse.ExtraButton13: "ExtraButton13",
    QtMouse.ExtraButton14: "ExtraButton14",
    QtMouse.ExtraButton15: "ExtraButton15",
    QtMouse.ExtraButton16: "ExtraButton16",
    QtMouse.ExtraButton17: "ExtraButton17",
    QtMouse.ExtraButton18: "ExtraButton18",
    QtMouse.ExtraButton19: "ExtraButton19",
    QtMouse.ExtraButton20: "ExtraButton20",
    QtMouse.ExtraButton21: "ExtraButton21",
    QtMouse.ExtraButton22: "ExtraButton22",
    QtMouse.ExtraButton23: "ExtraButton23",
    QtMouse.ExtraButton24: "ExtraButton24",
    #not actual buttons:
    #QtMouse.NoButton: "NoButton"
    #QtMouse.AllButtons: "AllButtons"
}
STRING_TO_MOUSE: dict[str, QtMouse] = {k: v for k, v in QtMouse.__dict__.items() if "Button" in k}
STRING_TO_MOUSE.update({v: k for k, v in MOUSE_BUTTON_NAMES.items()})

BUTTON_TO_INT: dict[QtMouse, int] = {
    QtMouse.LeftButton: int(QtMouse.LeftButton) if API_NAME in ("PyQt5", "PySide2") else QtMouse.LeftButton.value,
    QtMouse.RightButton: int(QtMouse.RightButton) if API_NAME in ("PyQt5", "PySide2") else QtMouse.RightButton.value,
    QtMouse.MiddleButton: int(QtMouse.MiddleButton) if API_NAME in ("PyQt5", "PySide2") else QtMouse.MiddleButton.value,
    QtMouse.BackButton: int(QtMouse.BackButton) if API_NAME in ("PyQt5", "PySide2") else QtMouse.BackButton.value,
    QtMouse.ForwardButton: int(QtMouse.ForwardButton) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ForwardButton.value,
    QtMouse.TaskButton: int(QtMouse.TaskButton) if API_NAME in ("PyQt5", "PySide2") else QtMouse.TaskButton.value,
    QtMouse.ExtraButton1: int(QtMouse.ExtraButton1) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton1.value,
    QtMouse.ExtraButton2: int(QtMouse.ExtraButton2) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton2.value,
    QtMouse.ExtraButton3: int(QtMouse.ExtraButton3) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton3.value,
    QtMouse.ExtraButton4: int(QtMouse.ExtraButton4) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton4.value,
    QtMouse.ExtraButton5: int(QtMouse.ExtraButton5) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton5.value,
    QtMouse.ExtraButton6: int(QtMouse.ExtraButton6) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton6.value,
    QtMouse.ExtraButton7: int(QtMouse.ExtraButton7) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton7.value,
    QtMouse.ExtraButton8: int(QtMouse.ExtraButton8) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton8.value,
    QtMouse.ExtraButton9: int(QtMouse.ExtraButton9) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton9.value,
    QtMouse.ExtraButton10: int(QtMouse.ExtraButton10) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton10.value,
    QtMouse.ExtraButton11: int(QtMouse.ExtraButton11) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton11.value,
    QtMouse.ExtraButton12: int(QtMouse.ExtraButton12) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton12.value,
    QtMouse.ExtraButton13: int(QtMouse.ExtraButton13) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton13.value,
    QtMouse.ExtraButton14: int(QtMouse.ExtraButton14) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton14.value,
    QtMouse.ExtraButton15: int(QtMouse.ExtraButton15) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton15.value,
    QtMouse.ExtraButton16: int(QtMouse.ExtraButton16) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton16.value,
    QtMouse.ExtraButton17: int(QtMouse.ExtraButton17) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton17.value,
    QtMouse.ExtraButton18: int(QtMouse.ExtraButton18) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton18.value,
    QtMouse.ExtraButton19: int(QtMouse.ExtraButton19) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton19.value,
    QtMouse.ExtraButton20: int(QtMouse.ExtraButton20) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton20.value,
    QtMouse.ExtraButton21: int(QtMouse.ExtraButton21) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton21.value,
    QtMouse.ExtraButton22: int(QtMouse.ExtraButton22) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton22.value,
    QtMouse.ExtraButton23: int(QtMouse.ExtraButton23) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton23.value,
    QtMouse.ExtraButton24: int(QtMouse.ExtraButton24) if API_NAME in ("PyQt5", "PySide2") else QtMouse.ExtraButton24.value,
    QtMouse.NoButton: int(QtMouse.NoButton) if API_NAME in ("PyQt5", "PySide2") else QtMouse.NoButton.value,
}
if API_NAME in ("PySide2", "PySide6"):
    BUTTON_TO_INT[QtMouse.MouseButtonMask] = int(QtMouse.MouseButtonMask) if API_NAME == "PySide2" else QtMouse.MouseButtonMask.value
INT_TO_BUTTON: dict[int, QtMouse] = {v: k for k, v in BUTTON_TO_INT.items()}

STRING_KEY_TO_INT: dict[str, int] = {k: v.value if API_NAME in ("PyQt6", "PySide6") else v for k, v in QtKey.__dict__.items() if k.startswith("Key_")}

def getQtKey(obj: QtKey | T) -> QtKey | T:
    if isinstance(obj, bytes):
        obj = obj.decode(errors="replace")

    key = MODIFIER_KEYNAME_TO_KEY.get(obj)
    if key is not None:
        return key
    if isinstance(obj, int) and API_NAME in ("PyQt6", "PySide6"):
        return QtKey(obj)
    if obj in dir(QtKey):
        return QtKey.__dict__[obj]

    # Convert the string to QKeySequence and extract the key code
    try:
        key_sequence = QKeySequence.fromString(obj)
    except TypeError:
        return obj
    return key_sequence[0] if key_sequence.count() > 0 else obj

def getQtKeyString(key: QtKey | T) -> str:
    result = getattr(key, "name", MODIFIER_KEY_NAMES.get(key, QKeySequence(key).toString()))  # type: ignore[arg-type]
    return result.decode(errors="replace") if isinstance(result, bytes) else result

def getQtKeyStringLocalized(key: QtKey | str | int | bytes):
    return MODIFIER_KEY_NAMES.get(key, getattr(key, "name", QKeySequence(key).toString())).upper().strip().replace("KEY_", "").replace("CONTROL", "CTRL")  # type: ignore[arg-type]

def getQtButtonString(button: QtMouse | int) -> str:
    # sourcery skip: assign-if-exp, reintroduce-else
    if isinstance(button, bytes):
        button = button.decode(errors="replace")
    attrButtonName = getattr(button, "name", None)
    if isinstance(attrButtonName, bytes):
        return attrButtonName.decode(errors="replace")
    if attrButtonName is None:
        return MOUSE_BUTTON_NAMES.get(button)
    return attrButtonName  # type: ignore[arg-type]

def getQtMouseButton(obj: QtMouse | T) -> QtMouse | T:
    # sourcery skip: assign-if-exp, reintroduce-else
    buttonFromString = STRING_TO_MOUSE.get(str(obj))
    if buttonFromString is not None:
        return buttonFromString
    buttonFromInt = INT_TO_BUTTON.get(obj)
    if buttonFromInt is not None:
        return buttonFromInt
    buttonFromDict = QtMouse.__dict__.get(obj)
    if buttonFromDict is not None:
        return buttonFromDict
    return None  # type: ignore[arg-type]

if __name__ == "__main__":  # quick test
    all_keys = [getattr(QtKey, key) for key in dir(QtKey) if key.startswith("Key_")]
    all_buttons = [getattr(QtMouse, button) for button in dir(QtMouse) if "Button" in button and button not in ("AllButtons", "NoButton")]

    for key in all_keys:
        key_string = getQtKeyString(key)
        key_from_string = getQtKey(key_string)
        assert key is key_from_string, f"Key str mismatch: {key} != {key_from_string}"
        key_int = STRING_KEY_TO_INT[key_string]
        key_from_int = QtKey(key_int)
        assert key_from_string == key_from_int, f"Key int mismatch: {key_from_string} ({id(key_from_string)}) != {key_from_int} ({id(key_from_int)})"

    for button in all_buttons:
        button_string = getQtButtonString(button)
        button_from_string = getQtMouseButton(button_string)
        assert button == button_from_string, f"Button str mismatch: {button} != {button_from_string}"
        button_int = BUTTON_TO_INT[button_from_string]
        button_from_int = INT_TO_BUTTON[button_int]
        assert button_from_string == button_from_int, f"Button int mismatch: {button_from_string} != {button_from_int}"

    print("All keys/buttons matched successfully!")

the asserts were originally using is. It seems ONLY pyside2 requires == for some reason.

No idea how this will work with different versions, these were tested on all 4 qt libs with the most up-to-date versions available on pip.

Is there perhaps anything else my test needs that I'm not thinking about, before I send away to production? Thanks!

ccordoba12 commented 1 month ago

Is there perhaps anything else my test needs that I'm not thinking about, before I send away to production? Thanks!

You're asking us to review a lot of non-trivial code that's not going to be part of this project. And, sorry to say it, we don't have time for that.

th3w1zard1 commented 1 month ago

Is there perhaps anything else my test needs that I'm not thinking about, before I send away to production? Thanks!

You're asking us to review a lot of non-trivial code that's not going to be part of this project. And, sorry to say it, we don't have time for that.

You are absolutely right and I apologize, at the time it seemed relevant.

dalthviz commented 2 weeks ago

Hey @th3w1zard1, thanks for reporting. Your suggestion sounds reasonable to me.

@dalthviz, what do you think?

The initial suggestion makes sense to me :+1:

Also, just in case, I think this behavior with PyQt6 is related with the deprecations done to QKeyCombination (int is deprecated and toCombined should be used and the & operator is deprecated and the | operator is the one recommended): https://doc.qt.io/qt-6/qkeycombination-obsolete.html

So, for example, with PyQt6/PySide6 you can do something like:

(Qt.KeyboardModifier.ControlModifier | Qt.Key.Key_A).toCombined()

But not something like:

int(Qt.KeyboardModifier.ControlModifier & Qt.Key.Key_A)