Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
th3w1zard1 opened this issue May 26, 2024 · 5 comments
Open

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

th3w1zard1 opened this issue May 26, 2024 · 5 comments

Comments

@th3w1zard1
Copy link

th3w1zard1 commented May 26, 2024

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
@th3w1zard1 th3w1zard1 changed the title Qt.MouseButtons and Qt.Key unification needed Qt.MouseButtons and Qt.Key potential unification for Qt5 vs Qt6 May 26, 2024
@ccordoba12
Copy link
Member

ccordoba12 commented May 26, 2024

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

@dalthviz, what do you think?

@th3w1zard1
Copy link
Author

th3w1zard1 commented May 26, 2024

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
Copy link
Member

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
Copy link
Author

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
Copy link
Member

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

@dalthviz, what do you think?

The initial suggestion makes sense to me 👍

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)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants