Repository: karlch/vimiv-qt (language: python)

Issue:
Support for (Py)Qt 6
As both Qt6 and PyQt6 have been released, we should think about how to support them. I suppose we will need to support both (Py)Qt5 and (Py)Qt6 for a while and then at some point in the probably distant future drop support for version 5. Therefore this will involve:

- [ ] adding our own wrapper module around PyQt and using it everywhere instead of importing PyQt5 directly
- [ ] changing the existing usage of (Py-)Qt-related code where needed
   - [ ] only use scoped enums, Florian Bruhin wrote an excellent script for this over here https://github.com/qutebrowser/qutebrowser/issues/5904
   - [ ] switch to `QRegularExpression` wherever we use `QRegExp`
   - [ ] removing the trailing `_` in `exec_` and `print_` calls
- [ ] figuring out which is the minimum required version of PyQt5 after these changes
   
and probably a lot more things I cannot think of at the moment :blush:



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8b8f2e74e..f8947e299 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -34,12 +34,16 @@ jobs:
             toxenv: pyqt515-cov
           - python: "3.11"
             toxenv: pyqt515-piexif-cov
+          - python: "3.11"
+            toxenv: pyqt6-cov
           - python: "3.11"
             toxenv: lint
           - python: "3.11"
             toxenv: packaging
           - python: "3.11"
-            toxenv: mypy
+            toxenv: mypy-pyqt5
+          - python: "3.11"
+            toxenv: mypy-pyqt6
       fail-fast: false
 
     steps:
@@ -57,7 +61,7 @@ jobs:
         python-version: "${{ matrix.python }}"
     - name: Install dependencies
       run: |
-        sudo apt-get update && sudo apt-get install libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libxcb-shape0
+        sudo apt-get install libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libxcb-shape0 libegl1 libxcb-cursor0
         python -m pip install --upgrade pip
         pip install -r misc/requirements/requirements_tox.txt
     - name: Test with tox
diff --git a/.pydocstylerc b/.pydocstylerc
index 7429beb68..5b01d11c7 100644
--- a/.pydocstylerc
+++ b/.pydocstylerc
@@ -1,9 +1,9 @@
 [pydocstyle]
 # Disabled:
-# D101-105: Check for docstrings is handled by pylint.
+# D100-105: Check for docstrings is handled by pylint.
 # D107: Missing docstring in __init__, not always needed IMHO
 # D202: No blank lines allowed after function docstring, false positives with decorators
 # D402: First line should not be function's "signature", false positives
 # D413: Multi-line docstring summary should start at the second line
 # pep257: D203,D212,D213,D214,D215,D404,D405,D406,D407,D408,D409,D410,D411
-ignore = D101,D102,D103,D105,D105,D107,D202,D203,D212,D213,D214,D215,D401,D402,D404,D405,D406,D407,D408,D409,D410,D411,D413
+ignore = D100,D101,D102,D103,D105,D105,D107,D202,D203,D212,D213,D214,D215,D401,D402,D404,D405,D406,D407,D408,D409,D410,D411,D413
diff --git a/.pylintrc b/.pylintrc
index a0996aea6..5e1820332 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -44,7 +44,7 @@ no-docstring-rgx=(^__|^main$|^test_|decorator|inside|^_on)
 [FORMAT]
 max-line-length=88
 max-module-lines=1000
-ignore-long-lines=(<?https?://|^# Copyright 201\d|# (pylint|flake8): disable=)
+ignore-long-lines=(<?https?://|^# Copyright 201\d|# (pylint|flake8): disable=|# type: ignore)
 expected-line-ending-format=LF
 
 [SIMILARITIES]
@@ -58,3 +58,6 @@ max-args=10
 
 [CLASSES]
 valid-metaclass-classmethod-first-arg=cls
+
+[TYPECHECK]
+ignored-modules=PyQt5,PyQt6,PySide6
diff --git a/misc/requirements/requirements.txt b/misc/requirements/requirements.txt
deleted file mode 100644
index 6b0a57249..000000000
--- a/misc/requirements/requirements.txt
+++ /dev/null
@@ -1,1 +0,0 @@
-PyQt5==5.15.9
diff --git a/misc/requirements/requirements_pyqt5.txt b/misc/requirements/requirements_pyqt5.txt
new file mode 100644
index 000000000..981f7f5d7
--- /dev/null
+++ b/misc/requirements/requirements_pyqt5.txt
@@ -0,0 +1,1 @@
+PyQt5==5.15.7
diff --git a/misc/requirements/requirements_pyqt6.txt b/misc/requirements/requirements_pyqt6.txt
new file mode 100644
index 000000000..c97fde7b2
--- /dev/null
+++ b/misc/requirements/requirements_pyqt6.txt
@@ -0,0 +1,1 @@
+PyQt6==6.5.1
diff --git a/misc/requirements/requirements_pyside6.txt b/misc/requirements/requirements_pyside6.txt
new file mode 100644
index 000000000..7dd374c4b
--- /dev/null
+++ b/misc/requirements/requirements_pyside6.txt
@@ -0,0 +1,1 @@
+PySide6==6.4.1
diff --git a/mypy.ini b/mypy.ini
index 06ac1f149..86b8fc825 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -46,3 +46,10 @@ disallow_incomplete_defs = True
 [mypy-vimiv.utils]
 disallow_untyped_defs = False
 disallow_incomplete_defs = False
+
+# TODO re-think qt type checking
+[mypy-vimiv.qt.*]
+ignore_errors = True
+
+[mypy-vimiv.qt]
+ignore_errors = True
diff --git a/setup.py b/setup.py
index fd8940f0a..0ad6223ad 100644
--- a/setup.py
+++ b/setup.py
@@ -39,7 +39,6 @@ def read_from_init(name):
 
 setuptools.setup(
     python_requires=">=3.6",
-    install_requires=["PyQt5>=5.9.2"],
     packages=setuptools.find_packages(),
     ext_modules=[manipulate_module],
     entry_points={"gui_scripts": ["vimiv = vimiv.startup:main"]},
diff --git a/tox.ini b/tox.ini
index f32197fc8..68cf05d4e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = pyqt,pyqt-piexif,pyqt-pyexiv2,lint,packaging,mypy
+envlist = pyqt,pyqt-piexif,pyqt-pyexiv2,lint,packaging,mypy-pyqt5
 
 # Standard test suite using pytest
 [testenv]
@@ -9,12 +9,15 @@ passenv = PYTHONPATH, CI, USER, HOME, XDG_*, DISPLAY
 basepython = {env:PYTHON:python3}
 deps =
     -r{toxinidir}/misc/requirements/requirements_tests.txt
-    pyqt: -r{toxinidir}/misc/requirements/requirements.txt
     pyqt511: PyQt5==5.11.3
     pyqt512: PyQt5==5.12.3
     pyqt513: PyQt5==5.13.2
     pyqt514: PyQt5==5.14.2
-    pyqt515: -r{toxinidir}/misc/requirements/requirements.txt
+    pyqt515: -r{toxinidir}/misc/requirements/requirements_pyqt5.txt
+    pyqt: -r{toxinidir}/misc/requirements/requirements_pyqt5.txt
+    pyqt5: -r{toxinidir}/misc/requirements/requirements_pyqt5.txt
+    pyqt6: -r{toxinidir}/misc/requirements/requirements_pyqt6.txt
+    pyside6: -r{toxinidir}/misc/requirements/requirements_pyside6.txt
     cov: -r{toxinidir}/misc/requirements/requirements_cov.txt
     piexif: -r{toxinidir}/misc/requirements/requirements_piexif.txt
     pyexiv2: -r{toxinidir}/misc/requirements/requirements_pyexiv2.txt
@@ -26,7 +29,8 @@ commands = pytest {posargs}
 [testenv:lint]
 ignore_errors = true
 deps =
-    -r{toxinidir}/misc/requirements/requirements.txt
+    -r{toxinidir}/misc/requirements/requirements_pyqt5.txt
+    -r{toxinidir}/misc/requirements/requirements_pyqt6.txt
     -r{toxinidir}/misc/requirements/requirements_piexif.txt
     -r{toxinidir}/misc/requirements/requirements_lint.txt
 commands =
@@ -47,26 +51,32 @@ commands =
     check-manifest
 
 # Check for errors with type annotations
-[testenv:mypy]
+[testenv:mypy-{pyqt5,pyqt6,pyside6}]
 passenv = PYTHONPATH, CI, TERM
+setenv =
+    pyqt5: WRAPPER_ARGS=--always-true=USE_PYQT5 --always-false=USE_PYQT6 --always-false=USE_PYSIDE6
+    pyqt6: WRAPPER_ARGS=--always-true=USE_PYQT6 --always-false=USE_PYQT5 --always-false=USE_PYSIDE6
+    pyside6: WRAPPER_ARGS=--always-true=USE_PYSIDE6 --always-false=USE_PYQT5 --always-false=USE_PYQT6
 deps =
-    -r{toxinidir}/misc/requirements/requirements.txt
     -r{toxinidir}/misc/requirements/requirements_piexif.txt
     -r{toxinidir}/misc/requirements/requirements_mypy.txt
-commands = mypy vimiv
+    pyqt5: -r{toxinidir}/misc/requirements/requirements_pyqt5.txt
+    pyqt6: -r{toxinidir}/misc/requirements/requirements_pyqt6.txt
+    pyside6: -r{toxinidir}/misc/requirements/requirements_pyside6.txt
+commands = mypy {env:WRAPPER_ARGS} vimiv {posargs}
 
 # Create a virtual environment to run the development version
 [testenv:mkvenv]
 envdir = {toxinidir}/.venv
 usedevelop = true
 deps =
-    -r{toxinidir}/misc/requirements/requirements.txt
+    -r{toxinidir}/misc/requirements/requirements_pyqt5.txt
 commands = {envpython} -c ""
 
 # Create a virtual environment to build the documentation
 [testenv:docs]
 deps =
-    -r{toxinidir}/misc/requirements/requirements.txt
+    -r{toxinidir}/misc/requirements/requirements_pyqt5.txt
     -r{toxinidir}/misc/requirements/requirements_piexif.txt
     -r{toxinidir}/misc/requirements/requirements_docs.txt
 commands =
diff --git a/vimiv/api/__init__.py b/vimiv/api/__init__.py
index 0dc1f6092..33075b934 100644
--- a/vimiv/api/__init__.py
+++ b/vimiv/api/__init__.py
@@ -8,7 +8,7 @@
 
 import os
 from typing import List, Iterable, Callable, BinaryIO
-from PyQt5.QtGui import QPixmap
+from vimiv.qt.gui import QPixmap
 
 from vimiv.utils import files, imagereader
 
diff --git a/vimiv/api/_mark.py b/vimiv/api/_mark.py
index ec971053a..b61c4d183 100644
--- a/vimiv/api/_mark.py
+++ b/vimiv/api/_mark.py
@@ -12,7 +12,7 @@
 import shutil
 from typing import Any, Callable, List, Optional
 
-from PyQt5.QtCore import QObject, pyqtSignal, QFileSystemWatcher, QDateTime
+from vimiv.qt.core import QObject, pyqtSignal, QFileSystemWatcher, QDateTime
 
 from vimiv.api import commands, keybindings, objreg, status, settings, modes
 from vimiv.config import styles
diff --git a/vimiv/api/_modules.py b/vimiv/api/_modules.py
index a5eeecf3c..9b3e20b23 100644
--- a/vimiv/api/_modules.py
+++ b/vimiv/api/_modules.py
@@ -13,8 +13,8 @@
 import os
 from typing import List
 
-from PyQt5.QtCore import QDateTime
-from PyQt5.QtGui import QGuiApplication, QClipboard
+from vimiv.qt.core import QDateTime
+from vimiv.qt.gui import QGuiApplication, QClipboard
 
 from vimiv import api
 from vimiv.utils import files, log, imagereader
@@ -83,7 +83,7 @@ def copy_name(abspath: bool = False, primary: bool = False) -> None:
         * ``--primary``: Copy to primary selection.
     """
     clipboard = QGuiApplication.clipboard()
-    mode = QClipboard.Selection if primary else QClipboard.Clipboard
+    mode = QClipboard.Mode.Selection if primary else QClipboard.Mode.Clipboard
     path = api.current_path()
     name = path if abspath else os.path.basename(path)
     clipboard.setText(name, mode=mode)
@@ -113,7 +113,7 @@ def copy_image(
     **count:** Equivalent to the ``--size`` option
     """
     clipboard = QGuiApplication.clipboard()
-    mode = QClipboard.Selection if primary else QClipboard.Clipboard
+    mode = QClipboard.Mode.Selection if primary else QClipboard.Mode.Clipboard
     path = api.current_path()
 
     try:
@@ -156,7 +156,7 @@ def paste_name(primary: bool = True) -> None:
         * ``--primary``: Paste from  primary selection.
     """
     clipboard = QGuiApplication.clipboard()
-    mode = QClipboard.Selection if primary else QClipboard.Clipboard
+    mode = QClipboard.Mode.Selection if primary else QClipboard.Mode.Clipboard
     api.open_paths([clipboard.text(mode=mode)])
 
 
diff --git a/vimiv/api/completion.py b/vimiv/api/completion.py
index ee52771fa..9afd69cf7 100644
--- a/vimiv/api/completion.py
+++ b/vimiv/api/completion.py
@@ -54,8 +54,9 @@ def __init__(self):
 import re
 from typing import cast, Dict, Iterable, Tuple
 
-from PyQt5.QtCore import QSortFilterProxyModel, Qt
-from PyQt5.QtGui import QStandardItemModel, QStandardItem
+from vimiv import qt
+from vimiv.qt.core import QSortFilterProxyModel, Qt
+from vimiv.qt.gui import QStandardItemModel, QStandardItem
 
 from vimiv.api import modes, settings
 from vimiv.utils import log, escape_chars, unescape_chars
@@ -114,7 +115,7 @@ class FilterProxyModel(QSortFilterProxyModel):
     def __init__(self) -> None:
         super().__init__()
         self.setFilterKeyColumn(-1)  # Also filter in descriptions
-        self.setFilterCaseSensitivity(Qt.CaseInsensitive)
+        self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
         self.unmatched = ""
         self._empty = BaseModel("")
 
@@ -162,14 +163,22 @@ def _set_completion_regex(self, prefix: str, command: str) -> None:
             command = parts[-1]
         regex = prefix + f" *.*{command}.*"
         regex = regex.replace("\\", "\\\\")
-        self.setFilterRegExp(regex)
+        self._set_regex(regex)
+
+    def _set_regex(self, regex: str) -> None:
+        """Set filter regex according to Qt wrapper version."""
+        # TODO remove once we drop support for PyQt < 5.15
+        if qt.USE_PYQT5:
+            self.setFilterRegExp(regex)
+        else:
+            self.setFilterRegularExpression(regex)
 
     def _set_fuzzy_completion_regex(self, prefix: str, command: str) -> None:
-        self.setFilterRegExp(".*".join(prefix + command))
+        self._set_regex(".*".join(prefix + command))
 
     def reset(self) -> None:
         """Reset regular expression, unmatched string and source model."""
-        self.setFilterRegExp("")
+        self._set_regex("")
         self.unmatched = ""
         self.setSourceModel(self._empty)
 
diff --git a/vimiv/api/modes.py b/vimiv/api/modes.py
index da0ff629c..d2f1438d0 100644
--- a/vimiv/api/modes.py
+++ b/vimiv/api/modes.py
@@ -28,14 +28,13 @@
 """
 
 
-import abc
 from typing import cast, Any, Callable, List, Tuple
 
-from PyQt5.QtCore import pyqtSignal, QObject
-from PyQt5.QtWidgets import QWidget
+from vimiv.qt.core import pyqtSignal, QObject
+from vimiv.qt.widgets import QWidget
 
 from vimiv.api import settings
-from vimiv.utils import AbstractQObjectMeta, log
+from vimiv.utils import log
 
 
 _logger = log.module_logger(__name__)
@@ -45,7 +44,7 @@ class InvalidMode(Exception):
     """Exception raised when no valid mode could be found."""
 
 
-class Mode(QObject, metaclass=AbstractQObjectMeta):
+class Mode(QObject):
     """Base class for modes.
 
     Class Attributes:
@@ -164,9 +163,9 @@ def pathlist(self) -> List[str]:
     def current_path(self) -> str:
         return self.widget.current()
 
-    @abc.abstractmethod
     def _set_last(self, mode: "Mode") -> None:
         """Delegate storing the last mode to the child widget."""
+        raise NotImplementedError("Must be implemented by the child widget")
 
     def __eq__(self, other: Any) -> bool:
         if isinstance(other, Mode):
diff --git a/vimiv/api/prompt.py b/vimiv/api/prompt.py
index 013967acf..60f4442eb 100644
--- a/vimiv/api/prompt.py
+++ b/vimiv/api/prompt.py
@@ -8,7 +8,7 @@
 
 import typing
 
-from PyQt5.QtCore import QObject, pyqtSignal
+from vimiv.qt.core import QObject, pyqtSignal
 
 from vimiv.utils import log
 
diff --git a/vimiv/api/settings.py b/vimiv/api/settings.py
index 9f77d7409..677590618 100644
--- a/vimiv/api/settings.py
+++ b/vimiv/api/settings.py
@@ -10,16 +10,15 @@
     _storage: Initialized Storage object to store settings globally.
 """
 
-import abc
 import contextlib
 import enum
 import os
 from typing import Any, Dict, ItemsView, List, Callable, Iterable
 
-from PyQt5.QtCore import QObject, pyqtSignal
+from vimiv.qt.core import QObject, pyqtSignal
 
 from vimiv.api import prompt
-from vimiv.utils import clamp, AbstractQObjectMeta, log, customtypes, natural_sort
+from vimiv.utils import clamp, log, customtypes, natural_sort
 
 
 _storage: Dict[str, "Setting"] = {}
@@ -58,7 +57,7 @@ def items() -> ItemsView[str, "Setting"]:
     return _storage.items()
 
 
-class Setting(QObject, metaclass=AbstractQObjectMeta):
+class Setting(QObject):
     """Stores a setting and its attributes.
 
     This class can not be used directly. Instead it is used as BaseClass for
@@ -100,9 +99,9 @@ def __init__(
         _storage[name] = self  # Store setting in storage
 
     @property
-    @abc.abstractmethod
     def typ(self) -> type:
         """The python type of this setting defined by the child class."""
+        raise NotImplementedError("Must be implemented by child class")
 
     @property
     def default(self) -> Any:
diff --git a/vimiv/api/signals.py b/vimiv/api/signals.py
index 1468d4cbf..5690f3001 100644
--- a/vimiv/api/signals.py
+++ b/vimiv/api/signals.py
@@ -6,8 +6,8 @@
 
 """Namespace for signals exposed via the api."""
 
-from PyQt5.QtCore import QObject, pyqtSignal
-from PyQt5.QtGui import QPixmap, QMovie
+from vimiv.qt.core import QObject, pyqtSignal
+from vimiv.qt.gui import QPixmap, QMovie
 
 
 class _SignalHandler(QObject):
diff --git a/vimiv/api/status.py b/vimiv/api/status.py
index 43aa813a1..43eed45ef 100644
--- a/vimiv/api/status.py
+++ b/vimiv/api/status.py
@@ -36,7 +36,7 @@ def username():
 import re
 from typing import Callable, TypeVar, Any, Dict
 
-from PyQt5.QtCore import pyqtSignal, QObject
+from vimiv.qt.core import pyqtSignal, QObject
 
 from vimiv.api import objreg
 from vimiv.utils import log
diff --git a/vimiv/api/working_directory.py b/vimiv/api/working_directory.py
index 04a864cc2..d89411762 100644
--- a/vimiv/api/working_directory.py
+++ b/vimiv/api/working_directory.py
@@ -27,7 +27,7 @@
 Thus, if your custom class needs to know the current images and/or directories, it can
 connect to these signals::
 
-    from PyQt5.QtCore import QObject
+    from vimiv.qt.core import QObject
 
     from vimiv import api
 
@@ -61,7 +61,7 @@ def _on_im_changed(self, new_images, added, removed):
 import os
 from typing import cast, List, Tuple
 
-from PyQt5.QtCore import pyqtSignal, QFileSystemWatcher
+from vimiv.qt.core import pyqtSignal, QFileSystemWatcher
 
 from vimiv.api import settings, signals, status
 from vimiv.utils import files, slot, log, throttled
diff --git a/vimiv/app.py b/vimiv/app.py
index 498ab7922..6dbb77ef4 100644
--- a/vimiv/app.py
+++ b/vimiv/app.py
@@ -8,8 +8,8 @@
 
 import os
 
-from PyQt5.QtGui import QIcon
-from PyQt5.QtWidgets import QApplication
+from vimiv.qt.gui import QIcon
+from vimiv.qt.widgets import QApplication
 
 import vimiv
 from vimiv import api, utils
diff --git a/vimiv/checkversion.py b/vimiv/checkversion.py
index 46b19c9b6..d20a3c457 100644
--- a/vimiv/checkversion.py
+++ b/vimiv/checkversion.py
@@ -19,14 +19,6 @@
 
 import sys
 
-try:
-    from PyQt5.QtCore import PYQT_VERSION_STR
-
-    PYQT_VERSION = tuple(map(int, PYQT_VERSION_STR.split(".")))
-except ImportError:  # pragma: no cover  # PyQt is there in tests, using None is tested
-    # We check explicitly for None before using the tuple version
-    PYQT_VERSION = None  # type: ignore
-
 
 PYTHON_REQUIRED_VERSION = (3, 6)
 PYQT_REQUIRED_VERSION = (5, 9, 2)
@@ -41,10 +33,17 @@ def check_python_version():
 
 def check_pyqt_version():
     """Ensure the PyQt version is new enough."""
-    if PYQT_VERSION is None:
-        _exit("PyQt is required to run vimiv.\n")
-    elif PYQT_VERSION < PYQT_REQUIRED_VERSION:
-        _exit_version("PyQt", PYQT_REQUIRED_VERSION, PYQT_VERSION)
+    try:
+        from vimiv.qt.core import PYQT_VERSION_STR
+    except ImportError as e:  # TODO add back test for this
+        _exit(
+            f"Error importing a valid Qt python wrapper:\n{e}\n\n"
+            "Please install / configure a valid wrapper to run vimiv.\n"
+        )
+
+    pyqt_version = tuple(map(int, PYQT_VERSION_STR.split(".")))
+    if pyqt_version < PYQT_REQUIRED_VERSION:
+        _exit_version("PyQt", PYQT_REQUIRED_VERSION, pyqt_version)
 
 
 def join_version_tuple(version):
diff --git a/vimiv/commands/argtypes.py b/vimiv/commands/argtypes.py
index 44dd706a0..cdc88a3fe 100644
--- a/vimiv/commands/argtypes.py
+++ b/vimiv/commands/argtypes.py
@@ -15,7 +15,7 @@ def scroll(self, direction: argtypes.Direction):
 import contextlib
 import enum
 
-from PyQt5.QtCore import QSize
+from vimiv.qt.core import QSize
 
 
 class Direction(enum.Enum):
diff --git a/vimiv/commands/external.py b/vimiv/commands/external.py
index 10e8ffc1f..dcb278ea7 100644
--- a/vimiv/commands/external.py
+++ b/vimiv/commands/external.py
@@ -11,7 +11,7 @@
 import shlex
 from typing import List
 
-from PyQt5.QtCore import QProcess, QCoreApplication
+from vimiv.qt.core import QProcess, QCoreApplication
 
 from vimiv import api
 from vimiv.utils import log, flatten, contains_any, escape_glob, qbytearray_to_str
@@ -82,12 +82,12 @@ class _ExternalRunnerImpl(QProcess):
     """Runner for external commands."""
 
     error_messages = {
-        QProcess.FailedToStart: "command not found or not executable",
-        QProcess.Crashed: "process crashed after startup",
-        QProcess.Timedout: "process timed out",
-        QProcess.WriteError: "cannot write to process",
-        QProcess.ReadError: "cannot read from process",
-        QProcess.UnknownError: "unknown",
+        QProcess.ProcessError.FailedToStart: "command not found or not executable",
+        QProcess.ProcessError.Crashed: "process crashed after startup",
+        QProcess.ProcessError.Timedout: "process timed out",
+        QProcess.ProcessError.WriteError: "cannot write to process",
+        QProcess.ProcessError.ReadError: "cannot read from process",
+        QProcess.ProcessError.UnknownError: "unknown",
     }
 
     _instance = None
@@ -102,7 +102,7 @@ def __init__(self):
 
     def __call__(self, command: str, *args: str, pipe=False) -> None:
         """Run external command with arguments."""
-        if self.state() == QProcess.Running:
+        if self.state() == QProcess.ProcessState.Running:
             log.warning("Closing running process '%s'", self.program())
             self.close()
         self._pipe = pipe
@@ -114,7 +114,7 @@ def __call__(self, command: str, *args: str, pipe=False) -> None:
 
     def _on_finished(self, exitcode, exitstatus):
         """Check exit status and possibly process standard output on completion."""
-        if exitstatus != QProcess.NormalExit or exitcode != 0:
+        if exitstatus != QProcess.ExitStatus.NormalExit or exitcode != 0:
             log.error(
                 "Error running external process '%s':\n%s",
                 self.program(),
@@ -140,7 +140,7 @@ def _on_error(self, error):
         log.error("Error running '%s': %s", self.program(), self.error_messages[error])
 
     def _on_quit(self):
-        if self.state() == QProcess.Running:
+        if self.state() == QProcess.ProcessState.Running:
             log.warning(
                 "Decoupling external command '%s' with pid %d",
                 self.program(),
diff --git a/vimiv/commands/search.py b/vimiv/commands/search.py
index 963c0a1be..ed3cde373 100644
--- a/vimiv/commands/search.py
+++ b/vimiv/commands/search.py
@@ -13,7 +13,7 @@
 import fnmatch
 import os
 
-from PyQt5.QtCore import QObject, pyqtSignal
+from vimiv.qt.core import QObject, pyqtSignal
 
 from vimiv import api
 
diff --git a/vimiv/completion/completer.py b/vimiv/completion/completer.py
index 6ba4e10e6..1b2aeb77c 100644
--- a/vimiv/completion/completer.py
+++ b/vimiv/completion/completer.py
@@ -6,7 +6,7 @@
 
 """Completer class as man-in-the-middle between command line and completion."""
 
-from PyQt5.QtCore import QObject
+from vimiv.qt.core import QObject
 
 from vimiv import api, utils
 
diff --git a/vimiv/gui/commandline.py b/vimiv/gui/commandline.py
index 47d8b2e28..6574c110a 100644
--- a/vimiv/gui/commandline.py
+++ b/vimiv/gui/commandline.py
@@ -9,8 +9,8 @@
 import contextlib
 from typing import cast, TYPE_CHECKING
 
-from PyQt5.QtCore import QCoreApplication, QTimer
-from PyQt5.QtWidgets import QLineEdit
+from vimiv.qt.core import QCoreApplication, QTimer
+from vimiv.qt.widgets import QLineEdit
 
 from vimiv import api, utils
 from vimiv.commands import argtypes, runners, search
@@ -70,7 +70,7 @@ def init(self) -> None:
         self.textEdited.connect(self._on_text_edited)
         self.textChanged.connect(self._incremental_search)
         self.cursorPositionChanged.connect(self._on_cursor_position_changed)
-        QCoreApplication.instance().aboutToQuit.connect(  # type: ignore
+        QCoreApplication.instance().aboutToQuit.connect(  # type: ignore[arg-type,union-attr,unused-ignore]
             self._history.write
         )
 
diff --git a/vimiv/gui/commandwidget.py b/vimiv/gui/commandwidget.py
index 80cb692e8..6bd215e96 100644
--- a/vimiv/gui/commandwidget.py
+++ b/vimiv/gui/commandwidget.py
@@ -6,8 +6,8 @@
 
 """Command widget at the bottom including commandline and completion widget."""
 
-from PyQt5.QtCore import Qt
-from PyQt5.QtWidgets import QWidget, QSizePolicy, QVBoxLayout
+from vimiv.qt.core import Qt
+from vimiv.qt.widgets import QWidget, QSizePolicy, QVBoxLayout
 
 from vimiv import api
 from vimiv.completion import completer
@@ -27,7 +27,7 @@ class CommandWidget(QWidget):
     def __init__(self, mainwindow):
         super().__init__(parent=mainwindow)
 
-        self.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
+        self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
 
         self._commandline = commandline.CommandLine()
         self._completion_widget = completionwidget.CompletionView(mainwindow)
@@ -40,7 +40,7 @@ def __init__(self, mainwindow):
         layout.setContentsMargins(0, 0, 0, 0)
         layout.addWidget(self._completion_widget)
         layout.addWidget(self._commandline)
-        layout.setAlignment(Qt.AlignBottom)
+        layout.setAlignment(Qt.AlignmentFlag.AlignBottom)
 
         self._commandline.editingFinished.connect(self.leave_commandline)
 
diff --git a/vimiv/gui/completionwidget.py b/vimiv/gui/completionwidget.py
index 92d6c33f9..80b6e4869 100644
--- a/vimiv/gui/completionwidget.py
+++ b/vimiv/gui/completionwidget.py
@@ -6,7 +6,7 @@
 
 """Completion widget in the bar."""
 
-from PyQt5.QtCore import pyqtSignal, Qt
+from vimiv.qt.core import pyqtSignal, Qt
 
 from vimiv import api, widgets
 from vimiv.config import styles
@@ -44,8 +44,8 @@ class CompletionView(widgets.FlatTreeView):
     def __init__(self, parent):
         super().__init__(parent=parent)
 
-        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
-        self.setEditTriggers(self.NoEditTriggers)
+        self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+        self.setEditTriggers(self.EditTrigger.NoEditTriggers)
         self.setModel(api.completion.FilterProxyModel())
 
         styles.apply(self)
diff --git a/vimiv/gui/crop_widget.py b/vimiv/gui/crop_widget.py
index 6fbd95bc2..706211c69 100644
--- a/vimiv/gui/crop_widget.py
+++ b/vimiv/gui/crop_widget.py
@@ -6,9 +6,10 @@
 
 """Widget to display a rectangle for cropping and interact with image and transform."""
 
-from PyQt5.QtCore import Qt, QPoint, QRect, QRectF, QSize
-from PyQt5.QtGui import QPainter, QColor, QImage
-from PyQt5.QtWidgets import QApplication, QStyle, QStyleOption, QWidget
+from vimiv.qt import USE_PYQT5
+from vimiv.qt.core import Qt, QPoint, QRect, QRectF, QSize
+from vimiv.qt.gui import QPainter, QColor, QImage
+from vimiv.qt.widgets import QApplication, QStyle, QStyleOption, QWidget
 
 from vimiv import api
 from vimiv.config import styles
@@ -60,7 +61,7 @@ def __init__(self, image, aspectratio=None):
     def moving(self) -> bool:
         """True if the widget is currently being dragged."""
         cursor = QApplication.overrideCursor()
-        return cursor is not None and cursor.shape() == Qt.ClosedHandCursor
+        return cursor is not None and cursor.shape() == Qt.CursorShape.ClosedHandCursor
 
     def crop_rect(self) -> QRect:
         """Rectangle of the image that would currently be cropped."""
@@ -86,7 +87,7 @@ def resizeEvent(self, _event):
         """Update size fractions ensuring aspectratio."""
         if self._aspectratio.isValid():
             size = QSize(self._aspectratio)
-            size.scale(self.size(), Qt.KeepAspectRatio)
+            size.scale(self.size(), Qt.AspectRatioMode.KeepAspectRatio)
             self.resize(size)
         self.setGeometry(self.geometry() & self.image_rect)
         self.update_selected_rect()
@@ -103,7 +104,7 @@ def mousePressEvent(self, event):
         """Start dragging the widget if we click within it."""
         if self.geometry().contains(event.pos() + self.pos()):
             self._offset = event.pos()
-            QApplication.setOverrideCursor(Qt.ClosedHandCursor)
+            QApplication.setOverrideCursor(Qt.CursorShape.ClosedHandCursor)
 
     def mouseMoveEvent(self, event):
         """Move the widget if it is currently being dragged.
@@ -112,7 +113,10 @@ def mouseMoveEvent(self, event):
         is outside the parent widget, the movement is stopped.
         """
         if self.moving:
-            event_pos = event.globalPos()
+            if USE_PYQT5:
+                event_pos = event.globalPos()
+            else:
+                event_pos = event.globalPosition().toPoint()
             origin = self.image.mapToGlobal(QPoint(0, 0))
             if not self.image.geometry().contains(event_pos - origin):
                 return
@@ -139,7 +143,9 @@ def paintEvent(self, _event):
         opt = QStyleOption()
         opt.initFrom(self)
         painter = QPainter(self)
-        self.style().drawPrimitive(QStyle.PE_Widget, opt, painter, self)
+        self.style().drawPrimitive(
+            QStyle.PrimitiveElement.PE_Widget, opt, painter, self
+        )
 
 
 class CropOverlay(QWidget):
@@ -163,18 +169,20 @@ def update_geometry(self, rect, selected_rect):
         selected_rect.moveTopLeft(selected_rect.topLeft() - rect.topLeft())
         self._selected_rect = selected_rect
         if rect.size() != self._draw_buffer.size():
-            self._draw_buffer = QImage(rect.size(), QImage.Format_ARGB32_Premultiplied)
+            self._draw_buffer = QImage(
+                rect.size(), QImage.Format.Format_ARGB32_Premultiplied
+            )
         self.setGeometry(rect)
 
     def paintEvent(self, _event):
         """Paint dark rectangle over image and then clear the selection."""
         painter = QPainter(self._draw_buffer)
-        painter.setPen(Qt.NoPen)
+        painter.setPen(Qt.PenStyle.NoPen)
 
         painter.setBrush(self._shading_color)
-        painter.setCompositionMode(QPainter.CompositionMode_Source)
+        painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
         painter.drawRect(self.rect())
 
-        painter.setCompositionMode(QPainter.CompositionMode_Clear)
+        painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Clear)
         painter.drawRect(self._selected_rect)
         QPainter(self).drawImage(0, 0, self._draw_buffer)
diff --git a/vimiv/gui/eventhandler.py b/vimiv/gui/eventhandler.py
index faa5ce549..b4df783c5 100644
--- a/vimiv/gui/eventhandler.py
+++ b/vimiv/gui/eventhandler.py
@@ -9,8 +9,8 @@
 import string
 from typing import Union, Tuple, List, cast
 
-from PyQt5.QtCore import Qt, QTimer, QObject, pyqtSignal
-from PyQt5.QtGui import QKeySequence, QKeyEvent, QMouseEvent
+from vimiv.qt.core import Qt, QTimer, QObject, pyqtSignal
+from vimiv.qt.gui import QKeySequence, QKeyEvent, QMouseEvent
 
 from vimiv import api, utils
 from vimiv.commands import runners, search
@@ -208,17 +208,17 @@ def unprocessed_keys():
 def keyevent_to_sequence(event: QKeyEvent) -> SequenceT:
     """Convert QKeyEvent to meaningful string sequence."""
     modifiers = (
-        Qt.Key_Control,
-        Qt.Key_Alt,
-        Qt.Key_Shift,
-        Qt.Key_Meta,
-        Qt.Key_AltGr,
-        Qt.Key_Super_L,
-        Qt.Key_Super_R,
-        Qt.Key_Hyper_L,
-        Qt.Key_Hyper_R,
-        Qt.Key_Direction_L,
-        Qt.Key_Direction_R,
+        Qt.Key.Key_Control,
+        Qt.Key.Key_Alt,
+        Qt.Key.Key_Shift,
+        Qt.Key.Key_Meta,
+        Qt.Key.Key_AltGr,
+        Qt.Key.Key_Super_L,
+        Qt.Key.Key_Super_R,
+        Qt.Key.Key_Hyper_L,
+        Qt.Key.Key_Hyper_R,
+        Qt.Key.Key_Direction_L,
+        Qt.Key.Key_Direction_R,
     )
     if event.key() in modifiers:  # Only modifier pressed
         raise ValueError("Modifiers do not have a stand-alone name")
@@ -228,11 +228,11 @@ def keyevent_to_sequence(event: QKeyEvent) -> SequenceT:
 def mouseevent_to_sequence(event: QMouseEvent, prefix: str = "button") -> SequenceT:
     """Convert QMouseEvent to meaningful string sequence."""
     button_names = {
-        Qt.LeftButton: "left",
-        Qt.MiddleButton: "middle",
-        Qt.RightButton: "right",
-        Qt.BackButton: "back",
-        Qt.ForwardButton: "forward",
+        Qt.MouseButton.LeftButton: "left",
+        Qt.MouseButton.MiddleButton: "middle",
+        Qt.MouseButton.RightButton: "right",
+        Qt.MouseButton.BackButton: "back",
+        Qt.MouseButton.ForwardButton: "forward",
     }
     button = event.button()
     button_name = button_names[button] if button in button_names else str(button)
@@ -242,9 +242,9 @@ def mouseevent_to_sequence(event: QMouseEvent, prefix: str = "button") -> Sequen
 def _get_modifier_names(event: Union[QKeyEvent, QMouseEvent]) -> List[str]:
     """Return the names of all modifiers pressed in the event."""
     modmask2str = {
-        Qt.ControlModifier: "<ctrl>",
-        Qt.AltModifier: "<alt>",
-        Qt.MetaModifier: "<meta>",
+        Qt.KeyboardModifier.ControlModifier: "<ctrl>",
+        Qt.KeyboardModifier.AltModifier: "<alt>",
+        Qt.KeyboardModifier.MetaModifier: "<meta>",
     }
     modifiers = event.modifiers()
     return [mod_name for mask, mod_name in modmask2str.items() if modifiers & mask]
@@ -262,30 +262,30 @@ def _get_base_keysequence(event: QKeyEvent) -> SequenceT:
         Name of the main key press escaping special keys.
     """
     special_keys = {
-        Qt.Key_Space: "<space>",
-        Qt.Key_Backtab: "<tab>",
-        Qt.Key_Tab: "<tab>",
-        Qt.Key_Escape: "<escape>",
-        Qt.Key_Enter: "<enter>",
-        Qt.Key_Return: "<return>",
-        Qt.Key_Backspace: "<backspace>",
-        Qt.Key_Left: "<left>",
-        Qt.Key_Right: "<right>",
-        Qt.Key_Up: "<up>",
-        Qt.Key_Down: "<down>",
-        Qt.Key_Home: "<home>",
-        Qt.Key_End: "<end>",
-        Qt.Key_PageUp: "<page-up>",
-        Qt.Key_PageDown: "<page-down>",
+        Qt.Key.Key_Space: "<space>",
+        Qt.Key.Key_Backtab: "<tab>",
+        Qt.Key.Key_Tab: "<tab>",
+        Qt.Key.Key_Escape: "<escape>",
+        Qt.Key.Key_Enter: "<enter>",
+        Qt.Key.Key_Return: "<return>",
+        Qt.Key.Key_Backspace: "<backspace>",
+        Qt.Key.Key_Left: "<left>",
+        Qt.Key.Key_Right: "<right>",
+        Qt.Key.Key_Up: "<up>",
+        Qt.Key.Key_Down: "<down>",
+        Qt.Key.Key_Home: "<home>",
+        Qt.Key.Key_End: "<end>",
+        Qt.Key.Key_PageUp: "<page-up>",
+        Qt.Key.Key_PageDown: "<page-down>",
     }
     separator_keys = {
-        Qt.Key_Colon: "<colon>",
-        Qt.Key_Equal: "<equal>",
+        Qt.Key.Key_Colon: "<colon>",
+        Qt.Key.Key_Equal: "<equal>",
     }
     if event.key() in special_keys:
         # Parse shift here as the key does not support it otherwise
         text = special_keys[event.key()]  # type: ignore
-        if event.modifiers() & Qt.ShiftModifier:
+        if event.modifiers() & Qt.KeyboardModifier.ShiftModifier:
             return "<shift>", text
         return (text,)
     if event.key() in separator_keys:  # Required as configparser crashes otherwise
diff --git a/vimiv/gui/image.py b/vimiv/gui/image.py
index ad0e55c0f..c9406e8a8 100644
--- a/vimiv/gui/image.py
+++ b/vimiv/gui/image.py
@@ -9,8 +9,8 @@
 import contextlib
 from typing import List, Union, Optional, Callable
 
-from PyQt5.QtCore import Qt, QRectF, pyqtSignal
-from PyQt5.QtWidgets import (
+from vimiv.qt.core import Qt, QRectF, pyqtSignal
+from vimiv.qt.widgets import (
     QGraphicsView,
     QGraphicsScene,
     QFrame,
@@ -18,7 +18,8 @@
     QGraphicsPixmapItem,
     QLabel,
 )
-from PyQt5.QtGui import QMovie, QPixmap
+from vimiv.qt.gui import QMovie, QPixmap
+from vimiv.qt.svg import QtSvg
 
 from vimiv import api, imutils, utils
 from vimiv.imutils import slideshow
@@ -31,9 +32,7 @@
 )
 from vimiv.config import styles
 from vimiv.gui import eventhandler
-from vimiv.utils import lazy, log
-
-QtSvg = lazy.import_module("PyQt5.QtSvg", optional=True)
+from vimiv.utils import log
 
 
 INF = float("inf")
@@ -84,14 +83,14 @@ def __init__(self) -> None:
         self._scale = ImageScaleFloat(1.0)
         self.transformation_module: Optional[Callable[[], str]] = None
 
-        self.setResizeAnchor(QGraphicsView.AnchorViewCenter)
-        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
-        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
-        self.setFrameShape(QFrame.Box)
+        self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
+        self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+        self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+        self.setFrameShape(QFrame.Shape.Box)
         scene = QGraphicsScene()
         scene.setSceneRect(QRectF(0, 0, 1, 1))
         self.setScene(scene)
-        self.setOptimizationFlags(QGraphicsView.DontSavePainterState)
+        self.setOptimizationFlags(QGraphicsView.OptimizationFlag.DontSavePainterState)
 
         api.signals.pixmap_loaded.connect(self._load_pixmap)
         api.signals.movie_loaded.connect(self._load_movie)
@@ -123,7 +122,7 @@ def _load_pixmap(self, pixmap: QPixmap, keep_zoom: bool) -> None:
         """Load new pixmap into the graphics scene."""
         item = QGraphicsPixmapItem()
         item.setPixmap(pixmap)
-        item.setTransformationMode(Qt.SmoothTransformation)
+        item.setTransformationMode(Qt.TransformationMode.SmoothTransformation)
         self._update_scene(item, item.boundingRect(), keep_zoom)
 
     def _load_movie(self, movie: QMovie, keep_zoom: bool) -> None:
@@ -327,7 +326,7 @@ def play_or_pause(self):
         with contextlib.suppress(IndexError, AttributeError):  # No items, not a movie
             widget = self.items()[0].widget()
             movie = widget.movie()
-            movie.setPaused(not movie.state() == QMovie.Paused)
+            movie.setPaused(not movie.state() == QMovie.MovieState.Paused)
 
     @api.commands.register(mode=api.modes.IMAGE, edit=True)
     def straighten(self):
@@ -418,19 +417,19 @@ def _leave_event(self, event):
 
     def mousePressEvent(self, event):
         """Update mouse press event to start panning on left button."""
-        if event.button() == Qt.LeftButton:
-            self.setDragMode(QGraphicsView.ScrollHandDrag)
+        if event.button() == Qt.MouseButton.LeftButton:
+            self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
         super().mousePressEvent(event)
 
     def mouseReleaseEvent(self, event):
         """Update mouse release event to stop any panning."""
-        self.setDragMode(QGraphicsView.NoDrag)
+        self.setDragMode(QGraphicsView.DragMode.NoDrag)
         super().mouseReleaseEvent(event)
 
     def wheelEvent(self, event):
         """Update mouse wheel to zoom with control."""
         require_ctrl = api.settings.image.zoom_wheel_ctrl
-        if not require_ctrl or event.modifiers() & Qt.ControlModifier:
+        if not require_ctrl or event.modifiers() & Qt.KeyboardModifier.ControlModifier:
             # We divide by 120 as this is the regular delta multiple
             # See https://doc.qt.io/qt-5/qwheelevent.html#angleDelta
             steps = event.angleDelta().y() / 120
@@ -443,5 +442,7 @@ def wheelEvent(self, event):
 
     def focusOutEvent(self, event):
         """Stop slideshow when focusing another widget."""
-        if event.reason() != Qt.ActiveWindowFocusReason:  # Unfocused the whole window
+        if (
+            event.reason() != Qt.FocusReason.ActiveWindowFocusReason
+        ):  # Unfocused the whole window
             slideshow.stop()
diff --git a/vimiv/gui/keybindings_popup.py b/vimiv/gui/keybindings_popup.py
index c4426ea53..4b53805e8 100644
--- a/vimiv/gui/keybindings_popup.py
+++ b/vimiv/gui/keybindings_popup.py
@@ -8,7 +8,7 @@
 
 from typing import List, Tuple, Iterator, Set
 
-from PyQt5.QtWidgets import QLabel, QHBoxLayout, QVBoxLayout, QLayout, QLineEdit
+from vimiv.qt.widgets import QLabel, QHBoxLayout, QVBoxLayout, QLayout, QLineEdit
 
 import vimiv
 from vimiv import api, utils
@@ -64,7 +64,7 @@ def __init__(self, columns: int = 2, parent=None):
         self._search.setPlaceholderText("search")
 
         layout = QVBoxLayout()
-        layout.setSizeConstraint(QLayout.SetFixedSize)
+        layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
         content_layout = QHBoxLayout()
         for _ in range(columns):
             label = QLabel()
diff --git a/vimiv/gui/keyhintwidget.py b/vimiv/gui/keyhintwidget.py
index 90427b450..e506911f9 100644
--- a/vimiv/gui/keyhintwidget.py
+++ b/vimiv/gui/keyhintwidget.py
@@ -8,8 +8,8 @@
 
 from typing import Iterator, Tuple
 
-from PyQt5.QtCore import QTimer, Qt
-from PyQt5.QtWidgets import QLabel, QSizePolicy
+from vimiv.qt.core import QTimer, Qt
+from vimiv.qt.widgets import QLabel, QSizePolicy
 
 from vimiv import api, utils
 from vimiv.config import styles
@@ -49,8 +49,8 @@ def __init__(self, parent):
         self._mainwindow_bottom = 0
 
         styles.apply(self)
-        self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Minimum)
-        self.setTextFormat(Qt.RichText)
+        self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
+        self.setTextFormat(Qt.TextFormat.RichText)
 
         partial_handler = eventhandler.EventHandlerMixin.partial_handler
         partial_handler.partial_matches.connect(self._on_partial_matches)
diff --git a/vimiv/gui/library.py b/vimiv/gui/library.py
index 0962d6144..dc864d0ed 100644
--- a/vimiv/gui/library.py
+++ b/vimiv/gui/library.py
@@ -11,9 +11,9 @@
 import os
 from typing import List, Optional, Dict, NamedTuple, cast
 
-from PyQt5.QtCore import Qt, pyqtSlot
-from PyQt5.QtWidgets import QStyledItemDelegate, QSizePolicy, QStyle, QWidget
-from PyQt5.QtGui import QStandardItemModel, QColor, QTextDocument, QStandardItem
+from vimiv.qt.core import Qt, pyqtSlot
+from vimiv.qt.widgets import QStyledItemDelegate, QSizePolicy, QStyle, QWidget
+from vimiv.qt.gui import QStandardItemModel, QColor, QTextDocument, QStandardItem
 
 from vimiv import api, utils, widgets
 from vimiv.commands import argtypes, search, number_for_command
@@ -94,8 +94,8 @@ def __init__(self, mainwindow: QWidget):
         self._last_selected = ""
         self._positions: Dict[str, Position] = {}
 
-        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
-        self.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Ignored)
+        self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+        self.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Ignored)
 
         self.setModel(LibraryModel(self))
         self.setItemDelegate(LibraryDelegate())
@@ -567,7 +567,7 @@ def _draw_background(self, painter, option, index):
         color = self._get_background_color(index, option.state)
         painter.save()
         painter.setBrush(color)
-        painter.setPen(Qt.NoPen)
+        painter.setPen(Qt.PenStyle.NoPen)
         painter.drawRect(option.rect)
         painter.restore()
 
@@ -595,7 +595,7 @@ def _get_background_color(self, index, state):
             index: Index of the element indicating even/odd/highlighted.
             state: State of the index indicating selected.
         """
-        if state & QStyle.State_Selected:
+        if state & QStyle.StateFlag.State_Selected:
             if api.modes.current() == api.modes.LIBRARY:
                 return self.selection_bg
             return self.selection_bg_unfocus
@@ -623,11 +623,15 @@ def elided(self, text, font_metrics, width):
         # Html only surrounds the leading mark indicator as directories are never marked
         if text.startswith(self.mark_str):
             mark_stripped = strip_html(self.mark_str)
-            elided = font_metrics.elidedText(html_stripped, Qt.ElideMiddle, width)
+            elided = font_metrics.elidedText(
+                html_stripped, Qt.TextElideMode.ElideMiddle, width
+            )
             return elided.replace(mark_stripped, self.mark_str)
         # Html surrounds the full text as the file may be a directory which is displayed
         # in bold
-        elided = font_metrics.elidedText(html_stripped, Qt.ElideMiddle, width)
+        elided = font_metrics.elidedText(
+            html_stripped, Qt.TextElideMode.ElideMiddle, width
+        )
         return text.replace(html_stripped, elided)
 
 
diff --git a/vimiv/gui/mainwindow.py b/vimiv/gui/mainwindow.py
index a32643734..010a35012 100644
--- a/vimiv/gui/mainwindow.py
+++ b/vimiv/gui/mainwindow.py
@@ -8,7 +8,7 @@
 
 from typing import List
 
-from PyQt5.QtWidgets import QWidget, QStackedWidget, QGridLayout
+from vimiv.qt.widgets import QWidget, QStackedWidget, QGridLayout
 
 from vimiv import api, utils
 from vimiv.utils import migration
diff --git a/vimiv/gui/manipulate.py b/vimiv/gui/manipulate.py
index 2ae3e9484..f808c7699 100644
--- a/vimiv/gui/manipulate.py
+++ b/vimiv/gui/manipulate.py
@@ -8,11 +8,11 @@
 
 from typing import List, Optional
 
-from PyQt5.QtCore import Qt, QSize, QPoint
-from PyQt5.QtGui import QPixmap
-from PyQt5.QtWidgets import QWidget, QHBoxLayout, QLabel, QTabWidget
+from vimiv.qt.core import Qt, QSize, QPoint
+from vimiv.qt.gui import QPixmap
+from vimiv.qt.widgets import QWidget, QHBoxLayout, QLabel, QTabWidget
 
-from vimiv import api, utils, imutils
+from vimiv import api, utils, imutils, qt
 from vimiv.config import styles
 from vimiv.imutils import immanipulate
 from vimiv.gui import eventhandler
@@ -43,7 +43,7 @@ class Manipulate(eventhandler.EventHandlerMixin, QTabWidget):
     @api.objreg.register
     def __init__(self, mainwindow):
         super().__init__(parent=mainwindow)
-        self.setAttribute(Qt.WA_StyledBackground, True)
+        self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
 
         styles.apply(self)
         # Add all manipulations from immanipulate
@@ -173,12 +173,21 @@ def _rescale(self):
         """Rescale pixmap and geometry to fit."""
         assert self._pixmap is not None, "No pixmap to rescale"
         # Scale pixmap to fit into label
-        pixmap = self._pixmap.scaled(
-            self._max_size.width(),
-            self._max_size.height(),
-            aspectRatioMode=Qt.KeepAspectRatio,
-            transformMode=Qt.SmoothTransformation,
-        )
+        # Workaround for different keyword naming in PySide6
+        if qt.USE_PYSIDE6:
+            pixmap = self._pixmap.scaled(
+                self._max_size.width(),
+                self._max_size.height(),
+                aspectMode=Qt.AspectRatioMode.KeepAspectRatio,
+                mode=Qt.TransformationMode.SmoothTransformation,
+            )
+        else:
+            pixmap = self._pixmap.scaled(
+                self._max_size.width(),
+                self._max_size.height(),
+                aspectRatioMode=Qt.AspectRatioMode.KeepAspectRatio,
+                transformMode=Qt.TransformationMode.SmoothTransformation,
+            )
         self.setPixmap(pixmap)
         # Update geometry to only show pixmap
         x = self._bottom_right.x() - pixmap.width()
diff --git a/vimiv/gui/message.py b/vimiv/gui/message.py
index e155b64b9..2062935b3 100644
--- a/vimiv/gui/message.py
+++ b/vimiv/gui/message.py
@@ -6,8 +6,8 @@
 
 """Message widget to display temporary information to the user."""
 
-from PyQt5.QtCore import Qt, QTimer
-from PyQt5.QtWidgets import QLabel, QSizePolicy
+from vimiv.qt.core import Qt, QTimer
+from vimiv.qt.widgets import QLabel, QSizePolicy
 
 from vimiv import api, utils
 from vimiv.config import styles
@@ -34,8 +34,8 @@ def __init__(self, mainwindow):
         self._timer.setInterval(api.settings.statusbar.message_timeout.value)
         self._timer.setSingleShot(True)
 
-        self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Minimum)
-        self.setTextFormat(Qt.RichText)
+        self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
+        self.setTextFormat(Qt.TextFormat.RichText)
         self.setWordWrap(True)
 
         styles.apply(self)
diff --git a/vimiv/gui/metadatawidget.py b/vimiv/gui/metadatawidget.py
index dd3bb8854..f920d189c 100644
--- a/vimiv/gui/metadatawidget.py
+++ b/vimiv/gui/metadatawidget.py
@@ -9,8 +9,8 @@
 import itertools
 from typing import Optional
 
-from PyQt5.QtCore import Qt
-from PyQt5.QtWidgets import QLabel, QSizePolicy, QWidget
+from vimiv.qt.core import Qt
+from vimiv.qt.widgets import QLabel, QSizePolicy, QWidget
 
 from vimiv import api, utils
 from vimiv.imutils import exif
@@ -51,8 +51,8 @@ def __init__(self, parent: QWidget):
             super().__init__(parent=parent)
             styles.apply(self)
 
-            self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Minimum)
-            self.setTextFormat(Qt.RichText)
+            self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
+            self.setTextFormat(Qt.TextFormat.RichText)
 
             self._mainwindow_bottom = 0
             self._mainwindow_width = 0
diff --git a/vimiv/gui/prompt.py b/vimiv/gui/prompt.py
index 3cfafd54a..718fed67d 100644
--- a/vimiv/gui/prompt.py
+++ b/vimiv/gui/prompt.py
@@ -6,8 +6,8 @@
 
 """Blocking prompt widget asking the user a question."""
 
-from PyQt5.QtCore import Qt, QEventLoop
-from PyQt5.QtWidgets import QLabel
+from vimiv.qt.core import Qt, QEventLoop
+from vimiv.qt.widgets import QLabel
 
 from vimiv import api, utils
 from vimiv.config import styles
@@ -79,7 +79,7 @@ def bindings_table(cls):
     def run(self):
         """Run the blocking event loop."""
         _logger.debug("Running blocking %s", self)
-        self.loop.exec_()
+        self.loop.exec()
 
     def update_geometry(self, _width: int, bottom: int):
         y = bottom - self.height()
@@ -96,11 +96,11 @@ def leave(self, *, answer=None):
 
     def keyPressEvent(self, event):
         """Leave the prompt on a valid key binding."""
-        if event.key() == Qt.Key_Y:
+        if event.key() == Qt.Key.Key_Y:
             self.leave(answer=True)
-        elif event.key() in (Qt.Key_N, Qt.Key_Return):
+        elif event.key() in (Qt.Key.Key_N, Qt.Key.Key_Return):
             self.leave(answer=False)
-        elif event.key() == Qt.Key_Escape:
+        elif event.key() == Qt.Key.Key_Escape:
             self.leave()
 
     def focusOutEvent(self, event):
diff --git a/vimiv/gui/resize.py b/vimiv/gui/resize.py
index 2af7a4710..dc94a4f10 100644
--- a/vimiv/gui/resize.py
+++ b/vimiv/gui/resize.py
@@ -6,8 +6,8 @@
 
 """Utilities to make widgets nicely resizable using QSizeGrips."""
 
-from PyQt5.QtCore import Qt
-from PyQt5.QtWidgets import QApplication, QSizeGrip, QGridLayout
+from vimiv.qt.core import Qt, Align
+from vimiv.qt.widgets import QApplication, QSizeGrip, QGridLayout
 
 
 class SizeGrip1D(QSizeGrip):
@@ -23,7 +23,9 @@ def __init__(self, parent, *, vertical: bool):
         super().__init__(parent)
         self._minimum_size = parent.minimumSize()
         self._maximum_size = parent.maximumSize()
-        self._override_cursor = Qt.SizeVerCursor if vertical else Qt.SizeHorCursor
+        self._override_cursor = (
+            Qt.CursorShape.SizeVerCursor if vertical else Qt.CursorShape.SizeHorCursor
+        )
         self.destroyed.connect(QApplication.restoreOverrideCursor)
 
     @property
@@ -67,22 +69,22 @@ def __init__(self, parent, *, fixed_aspectratio=False):
 
     def _add_corners(self, parent):
         """Add one QSizeGrip to each corner."""
-        self.addWidget(QSizeGrip(parent), 0, 0, Qt.AlignLeft | Qt.AlignTop)
-        self.addWidget(QSizeGrip(parent), 2, 0, Qt.AlignLeft | Qt.AlignBottom)
-        self.addWidget(QSizeGrip(parent), 0, 2, Qt.AlignRight | Qt.AlignTop)
-        self.addWidget(QSizeGrip(parent), 2, 2, Qt.AlignRight | Qt.AlignBottom)
+        self.addWidget(QSizeGrip(parent), 0, 0, Align.Left | Align.Top)
+        self.addWidget(QSizeGrip(parent), 2, 0, Align.Left | Align.Bottom)
+        self.addWidget(QSizeGrip(parent), 0, 2, Align.Right | Align.Top)
+        self.addWidget(QSizeGrip(parent), 2, 2, Align.Right | Align.Bottom)
 
     def _add_edges(self, parent):
         """Add one QSizeGrip to each edge, constrained to the corresponding axis."""
         self.addWidget(
-            SizeGrip1D(parent, vertical=True), 0, 1, Qt.AlignCenter | Qt.AlignTop
+            SizeGrip1D(parent, vertical=True), 0, 1, Align.Center | Align.Top
         )
         self.addWidget(
-            SizeGrip1D(parent, vertical=True), 2, 1, Qt.AlignCenter | Qt.AlignBottom
+            SizeGrip1D(parent, vertical=True), 2, 1, Align.Center | Align.Bottom
         )
         self.addWidget(
-            SizeGrip1D(parent, vertical=False), 1, 0, Qt.AlignLeft | Qt.AlignCenter
+            SizeGrip1D(parent, vertical=False), 1, 0, Align.Left | Align.Center
         )
         self.addWidget(
-            SizeGrip1D(parent, vertical=False), 1, 2, Qt.AlignRight | Qt.AlignCenter
+            SizeGrip1D(parent, vertical=False), 1, 2, Align.Right | Align.Center
         )
diff --git a/vimiv/gui/statusbar.py b/vimiv/gui/statusbar.py
index 58f9c9994..019e83341 100644
--- a/vimiv/gui/statusbar.py
+++ b/vimiv/gui/statusbar.py
@@ -16,8 +16,8 @@
 
 import re
 
-from PyQt5.QtCore import Qt
-from PyQt5.QtWidgets import QLabel, QWidget, QHBoxLayout
+from vimiv.qt.core import Qt
+from vimiv.qt.widgets import QLabel, QWidget, QHBoxLayout
 
 from vimiv import api, utils
 from vimiv.config import styles
@@ -49,9 +49,9 @@ def __init__(self):
         layout = QHBoxLayout(self)
         layout.setSpacing(0)
         layout.setContentsMargins(0, 0, 0, 0)
-        self.left = self.create_label(layout, Qt.AlignLeft)
-        self.center = self.create_label(layout, Qt.AlignCenter)
-        self.right = self.create_label(layout, Qt.AlignRight)
+        self.left = self.create_label(layout, Qt.AlignmentFlag.AlignLeft)
+        self.center = self.create_label(layout, Qt.AlignmentFlag.AlignCenter)
+        self.right = self.create_label(layout, Qt.AlignmentFlag.AlignRight)
 
         styles.apply(self)
         api.status.signals.update.connect(self._update_status)
@@ -70,7 +70,7 @@ def create_label(cls, layout, alignment):
         """Create a label with this alignment and add it to the layout."""
         label = QLabel()
         label.setAlignment(alignment)
-        label.setTextFormat(Qt.RichText)
+        label.setTextFormat(Qt.TextFormat.RichText)
         layout.addWidget(label)
         return label
 
diff --git a/vimiv/gui/straightenwidget.py b/vimiv/gui/straightenwidget.py
index 3f8475ed4..fe107c8ab 100644
--- a/vimiv/gui/straightenwidget.py
+++ b/vimiv/gui/straightenwidget.py
@@ -8,9 +8,9 @@
 
 import functools
 
-from PyQt5.QtCore import Qt
-from PyQt5.QtGui import QPainter, QPen, QColor
-from PyQt5.QtWidgets import QStyleOption
+from vimiv.qt.core import Qt
+from vimiv.qt.gui import QPainter, QPen, QColor
+from vimiv.qt.widgets import QStyleOption
 
 from vimiv import utils
 from vimiv.config import styles
@@ -32,9 +32,9 @@ class StraightenWidget(transformwidget.TransformWidget):
     """
 
     LINES = (
-        (0.25, Qt.DashLine, 2),
-        (0.5, Qt.SolidLine, 4),
-        (0.75, Qt.DashLine, 2),
+        (0.25, Qt.PenStyle.DashLine, 2),
+        (0.5, Qt.PenStyle.SolidLine, 4),
+        (0.75, Qt.PenStyle.DashLine, 2),
     )
 
     def __init__(self, image):
diff --git a/vimiv/gui/synchronize.py b/vimiv/gui/synchronize.py
index 7f856775e..03ef32dc6 100644
--- a/vimiv/gui/synchronize.py
+++ b/vimiv/gui/synchronize.py
@@ -11,7 +11,7 @@
 """
 
 
-from PyQt5.QtCore import pyqtSignal, QObject
+from vimiv.qt.core import pyqtSignal, QObject
 
 
 class _Signals(QObject):
diff --git a/vimiv/gui/thumbnail.py b/vimiv/gui/thumbnail.py
index 19b9f161f..a0b3d8391 100644
--- a/vimiv/gui/thumbnail.py
+++ b/vimiv/gui/thumbnail.py
@@ -11,9 +11,9 @@
 import os
 from typing import List, Optional, Iterator, cast
 
-from PyQt5.QtCore import Qt, QSize, QRect, pyqtSlot
-from PyQt5.QtWidgets import QListWidget, QListWidgetItem, QStyle, QStyledItemDelegate
-from PyQt5.QtGui import QColor, QIcon
+from vimiv.qt.core import Qt, QSize, QRect, pyqtSlot
+from vimiv.qt.widgets import QListWidget, QListWidgetItem, QStyle, QStyledItemDelegate
+from vimiv.qt.gui import QColor, QIcon
 
 from vimiv import api, utils, imutils, widgets
 from vimiv.commands import argtypes, search, number_for_command
@@ -93,11 +93,11 @@ def __init__(self) -> None:
         )
         self._manager = thumbnail_manager.ThumbnailManager(fail_pixmap)
 
-        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
-        self.setViewMode(QListWidget.IconMode)
+        self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+        self.setViewMode(QListWidget.ViewMode.IconMode)
         default_size = api.settings.thumbnail.size.value
         self.setIconSize(QSize(default_size, default_size))
-        self.setResizeMode(QListWidget.Adjust)
+        self.setResizeMode(QListWidget.ResizeMode.Adjust)
 
         self.setItemDelegate(ThumbnailDelegate(self))
         self.setDragEnabled(False)
@@ -502,7 +502,7 @@ def _draw_background(self, painter, option, item):
         color = self._get_background_color(item, option.state)
         painter.save()
         painter.setBrush(color)
-        painter.setPen(Qt.NoPen)
+        painter.setPen(Qt.PenStyle.NoPen)
         painter.drawRect(option.rect)
         painter.restore()
 
@@ -528,7 +528,7 @@ def _draw_pixmap(self, painter, option, item):
             option.rect.height() - 2 * self.padding,
         )
         # Size the pixmap should take
-        size = pixmap.size().scaled(rect.size(), Qt.KeepAspectRatio)
+        size = pixmap.size().scaled(rect.size(), Qt.AspectRatioMode.KeepAspectRatio)
         # Coordinates to center the pixmap
         diff_x = (rect.width() - size.width()) / 2.0
         diff_y = (rect.height() - size.height()) / 2.0
@@ -554,7 +554,7 @@ def _draw_mark(self, painter, option, x, y):
         width = int(max(min(0.05 * option.rect.width(), self.padding), 4))
         painter.save()
         painter.setBrush(self.mark_bg)
-        painter.setPen(Qt.NoPen)
+        painter.setPen(Qt.PenStyle.NoPen)
         painter.drawRect(x - width // 2, y - width // 2, width, width)
         painter.restore()
 
@@ -567,7 +567,7 @@ def _get_background_color(self, item, state):
             item: The ThumbnailItem storing highlight status.
             state: State of the model index indicating selected.
         """
-        if state & QStyle.State_Selected:
+        if state & QStyle.StateFlag.State_Selected:
             if api.modes.current() == api.modes.THUMBNAIL:
                 return self.selection_bg
             return self.selection_bg_unfocus
diff --git a/vimiv/gui/transformwidget.py b/vimiv/gui/transformwidget.py
index fc6d93916..90e95a8c0 100644
--- a/vimiv/gui/transformwidget.py
+++ b/vimiv/gui/transformwidget.py
@@ -6,22 +6,21 @@
 
 """Base class for widgets which provide a gui for more complex transformations."""
 
-import abc
 import contextlib
 import functools
 from typing import cast
 
-from PyQt5.QtCore import Qt, QRect
-from PyQt5.QtWidgets import QWidget
+from vimiv.qt.core import Qt, QRect
+from vimiv.qt.widgets import QWidget
 
 from vimiv.imutils import imtransform
 
-from vimiv import api, utils
+from vimiv import api
 from vimiv.gui import eventhandler
 from .image import ScrollableImage
 
 
-class TransformWidget(QWidget, metaclass=utils.AbstractQObjectMeta):
+class TransformWidget(QWidget):
     """Base class for widgets which provide a gui for more complex transformations.
 
     The child class must implement update_geometry to adapt to a resized image and
@@ -36,7 +35,7 @@ class TransformWidget(QWidget, metaclass=utils.AbstractQObjectMeta):
     def __init__(self, image):
         super().__init__(parent=image)
         self.setObjectName(self.__class__.__qualname__)
-        self.setWindowFlags(Qt.SubWindow)
+        self.setWindowFlags(Qt.WindowType.SubWindow)
         self.setFocus()
 
         self.bindings = {
@@ -54,9 +53,9 @@ def __init__(self, image):
     def image(self) -> ScrollableImage:
         return cast(ScrollableImage, self.parent())
 
-    @abc.abstractmethod
     def update_geometry(self):
         """Update geometry of the widget."""
+        raise NotImplementedError("Must be implemented by the actual transformation")
 
     def status_info(self) -> str:
         """Can be overridden by the child to display information in the status bar."""
@@ -96,8 +95,8 @@ def keyPressEvent(self, event):
     def focusOutEvent(self, event):
         """Leave the widget when the user focuses another widget."""
         ignored_reasons = (
-            Qt.ActiveWindowFocusReason,  # Unfocused the whole window
-            Qt.OtherFocusReason,  # Unfocused explicitly during leave
+            Qt.FocusReason.ActiveWindowFocusReason,  # Unfocused the whole window
+            Qt.FocusReason.OtherFocusReason,  # Unfocused explicitly during leave
         )
         if event.reason() not in ignored_reasons:
             self.leave(accept=False)
diff --git a/vimiv/gui/version_popup.py b/vimiv/gui/version_popup.py
index 4846c0eaf..d07e8762f 100644
--- a/vimiv/gui/version_popup.py
+++ b/vimiv/gui/version_popup.py
@@ -6,8 +6,8 @@
 
 """Pop-up window to display version information."""
 
-from PyQt5.QtWidgets import QLabel, QVBoxLayout, QPushButton
-from PyQt5.QtGui import QGuiApplication
+from vimiv.qt.widgets import QLabel, QVBoxLayout, QPushButton
+from vimiv.qt.gui import QGuiApplication
 
 import vimiv.version
 from vimiv.widgets import PopUp
diff --git a/vimiv/imutils/_file_handler.py b/vimiv/imutils/_file_handler.py
index 5bfd1f8ea..407cb81c7 100644
--- a/vimiv/imutils/_file_handler.py
+++ b/vimiv/imutils/_file_handler.py
@@ -11,13 +11,12 @@
 import tempfile
 from typing import List
 
-from PyQt5.QtCore import QObject, QCoreApplication
-from PyQt5.QtGui import QPixmap, QImageReader, QMovie
+from vimiv.qt.core import QObject, QCoreApplication
+from vimiv.qt.gui import QPixmap, QImageReader, QMovie
+from vimiv.qt.svg import QtSvg
 
 from vimiv import api, utils, imutils
-from vimiv.utils import files, log, asyncrun, lazy, imagereader
-
-QtSvg = lazy.import_module("PyQt5.QtSvg", optional=True)
+from vimiv.utils import files, log, asyncrun, imagereader
 
 
 _logger = log.module_logger(__name__)
diff --git a/vimiv/imutils/current_pixmap.py b/vimiv/imutils/current_pixmap.py
index b34cd0fb0..301700fb4 100644
--- a/vimiv/imutils/current_pixmap.py
+++ b/vimiv/imutils/current_pixmap.py
@@ -6,7 +6,7 @@
 
 """Storage class for the current pixmap."""
 
-from PyQt5.QtGui import QPixmap
+from vimiv.qt.gui import QPixmap
 
 
 class CurrentPixmap:
diff --git a/vimiv/imutils/edit_handler.py b/vimiv/imutils/edit_handler.py
index 66486948e..aec206417 100644
--- a/vimiv/imutils/edit_handler.py
+++ b/vimiv/imutils/edit_handler.py
@@ -6,8 +6,8 @@
 
 """Handler class as man-in-the-middle between file handler and the edit classes."""
 
-from PyQt5.QtCore import QObject
-from PyQt5.QtGui import QPixmap
+from vimiv.qt.core import QObject
+from vimiv.qt.gui import QPixmap
 
 from vimiv import api, utils
 from vimiv.imutils import current_pixmap, imtransform
diff --git a/vimiv/imutils/filelist.py b/vimiv/imutils/filelist.py
index 0acaef562..c773ccc84 100644
--- a/vimiv/imutils/filelist.py
+++ b/vimiv/imutils/filelist.py
@@ -14,7 +14,7 @@
 import random
 from typing import List, Iterable, Optional
 
-from PyQt5.QtCore import QObject, pyqtSlot
+from vimiv.qt.core import QObject, pyqtSlot
 
 from vimiv import api, utils, imutils
 from vimiv.commands import search, number_for_command
diff --git a/vimiv/imutils/immanipulate.py b/vimiv/imutils/immanipulate.py
index 3cef12abc..7f792865a 100644
--- a/vimiv/imutils/immanipulate.py
+++ b/vimiv/imutils/immanipulate.py
@@ -26,11 +26,11 @@
 import copy
 from typing import Optional, NamedTuple, List
 
-from PyQt5.QtCore import QObject, pyqtSignal, Qt, QSignalBlocker, QTimer
-from PyQt5.QtGui import QPixmap, QImage
-from PyQt5.QtWidgets import QLabel, QApplication
+from vimiv.qt.core import QObject, pyqtSignal, Qt, QSignalBlocker, QTimer
+from vimiv.qt.gui import QPixmap, QImage
+from vimiv.qt.widgets import QLabel, QApplication
 
-from vimiv import api, utils, widgets
+from vimiv import api, utils, widgets, qt
 from vimiv.config import styles
 
 # mypy cannot read the C extension
@@ -77,7 +77,7 @@ def __init__(
             "{manipulate.slider.left}",
             "{manipulate.slider.handle}",
             "{manipulate.slider.right}",
-            Qt.Horizontal,
+            Qt.Orientation.Horizontal,
         )
         self.slider.setMinimum(lower)
         self.slider.setMaximum(upper)
@@ -298,11 +298,17 @@ def apply_groups(self, pixmap: QPixmap, *groups: ManipulationGroup) -> QPixmap:
             The manipulated pixmap.
         """
         _logger.debug("Manipulate: applying %d groups", len(groups))
-        # Convert original pixmap to python bytes
         image = pixmap.toImage()
         bits = image.constBits()
-        bits.setsize(image.byteCount())
-        data = bits.asstring()
+        # Convert original pixmap to python bytes
+        if qt.USE_PYSIDE6:
+            data = bits.tobytes()
+        elif qt.USE_PYQT6:
+            bits.setsize(image.sizeInBytes())
+            data = bits.asstring()
+        else:
+            bits.setsize(image.byteCount())
+            data = bits.asstring()
         # Apply changes on the byte-level
         for group in groups:
             data = self._apply_group(group, data)
@@ -534,13 +540,22 @@ def _enter(self):
                 0, lambda: utils.log.error("File format does not support manipulate")
             )
             return
-        screen_geometry = QApplication.desktop().screenGeometry()
-        self._pixmap = self._current_pixmap.pixmap.scaled(
-            screen_geometry.width(),
-            screen_geometry.height(),
-            aspectRatioMode=Qt.KeepAspectRatio,
-            transformMode=Qt.SmoothTransformation,
-        )
+        screen_geometry = QApplication.primaryScreen().geometry()
+        # Workaround for different keyword naming in PySide6
+        if qt.USE_PYSIDE6:
+            self._pixmap = self._current_pixmap.pixmap.scaled(
+                screen_geometry.width(),
+                screen_geometry.height(),
+                aspectMode=Qt.AspectRatioMode.KeepAspectRatio,
+                mode=Qt.TransformationMode.SmoothTransformation,
+            )
+        else:
+            self._pixmap = self._current_pixmap.pixmap.scaled(
+                screen_geometry.width(),
+                screen_geometry.height(),
+                aspectRatioMode=Qt.AspectRatioMode.KeepAspectRatio,
+                transformMode=Qt.TransformationMode.SmoothTransformation,
+            )
         self.updated.emit(self._pixmap)
 
     def _on_updated(self, pixmap):
diff --git a/vimiv/imutils/imtransform.py b/vimiv/imutils/imtransform.py
index 0c60e3f05..4c0e9b0d9 100644
--- a/vimiv/imutils/imtransform.py
+++ b/vimiv/imutils/imtransform.py
@@ -10,8 +10,8 @@
 import math
 from typing import Optional
 
-from PyQt5.QtCore import Qt, QRect, QSize, QObject, pyqtSignal
-from PyQt5.QtGui import QTransform, QPixmap
+from vimiv.qt.core import Qt, QRect, QSize, QObject, pyqtSignal
+from vimiv.qt.gui import QTransform, QPixmap
 
 from vimiv import api
 from vimiv.utils import log
@@ -150,7 +150,11 @@ def rescale(self, dx: float, dy: Optional[float]):
 
     def apply(self):
         """Apply all transformations to the original pixmap."""
-        self._apply(self.original.transformed(self, mode=Qt.SmoothTransformation))
+        self._apply(
+            self.original.transformed(
+                self, mode=Qt.TransformationMode.SmoothTransformation
+            )
+        )
 
     def straighten(self, *, angle: int, original_size: QSize):
         """Straighten the original image.
@@ -163,7 +167,9 @@ def straighten(self, *, angle: int, original_size: QSize):
             original_size: Size of the original unstraightened image.
         """
         self.rotate(angle)
-        transformed = self.original.transformed(self, mode=Qt.SmoothTransformation)
+        transformed = self.original.transformed(
+            self, mode=Qt.TransformationMode.SmoothTransformation
+        )
         rect = self.largest_rect_in_rotated(
             original=original_size, rotated=transformed.size(), angle=angle
         )
diff --git a/vimiv/imutils/slideshow.py b/vimiv/imutils/slideshow.py
index db0466bcc..08f8aaa16 100644
--- a/vimiv/imutils/slideshow.py
+++ b/vimiv/imutils/slideshow.py
@@ -8,7 +8,7 @@
 
 from typing import Optional
 
-from PyQt5.QtCore import QTimer
+from vimiv.qt.core import QTimer
 
 from vimiv import api
 
diff --git a/vimiv/parser.py b/vimiv/parser.py
index 042598cec..ef7c240b0 100644
--- a/vimiv/parser.py
+++ b/vimiv/parser.py
@@ -13,7 +13,7 @@
 import shlex
 from typing import List
 
-from PyQt5.QtCore import QSize
+from vimiv.qt.core import QSize
 
 import vimiv
 
diff --git a/vimiv/plugins/print.py b/vimiv/plugins/print.py
index 6dafeac07..6921a16ef 100644
--- a/vimiv/plugins/print.py
+++ b/vimiv/plugins/print.py
@@ -9,14 +9,18 @@
 import abc
 from typing import Optional, Any, Union
 
-from PyQt5.QtCore import QObject, Qt, QSize, pyqtBoundSignal
-from PyQt5.QtGui import QPixmap, QMovie, QPainter
-from PyQt5.QtPrintSupport import QPrintDialog, QPrintPreviewDialog, QPrinter
+from vimiv.qt.core import QObject, Qt, QSize, pyqtBoundSignal
+from vimiv.qt.gui import QPixmap, QMovie, QPainter
+from vimiv.qt.printsupport import (
+    QPrintDialog,
+    QPrintPreviewDialog,
+    QPrinter,
+    Orientation,
+)
+from vimiv.qt.svg import QtSvg
 
-from vimiv import api
-from vimiv.utils import slot, log, lazy
-
-QtSvg = lazy.import_module("PyQt5.QtSvg", optional=True)
+from vimiv import api, qt
+from vimiv.utils import slot, log
 
 
 _logger = log.module_logger(__name__)
@@ -102,7 +106,10 @@ class PrintWidget(abc.ABC):
     def print(self, printer: QPrinter, auto_apply_orientation: bool = False) -> None:
         """Print the widget."""
         if auto_apply_orientation and self.size.width() > self.size.height():
-            printer.setOrientation(QPrinter.Landscape)
+            if qt.USE_PYQT5:
+                printer.setOrientation(Orientation.Landscape)
+            else:
+                printer.setPageOrientation(Orientation.Landscape)
         self.paint(printer)
 
     @abc.abstractmethod
@@ -125,8 +132,9 @@ def paint(self, printer: QPrinter) -> None:
         """Scale pixmap to match printer page and paint using painter."""
         _logger.debug("Painting pixmap for print")
         painter = QPainter(printer)
+        page_size = printer.pageRect(printer.Unit.DevicePixel).toRect().size()
         scaled_pixmap = self._widget.scaled(
-            printer.pageRect().size(), Qt.KeepAspectRatio
+            page_size, Qt.AspectRatioMode.KeepAspectRatio
         )
         painter.drawPixmap(0, 0, scaled_pixmap)
         painter.end()
@@ -139,15 +147,16 @@ def size(self) -> QSize:
 class PrintSvg(PrintWidget):
     """Print class for svg vector graphics."""
 
-    def __init__(self, svg_widget: "QtSvg.QSvgWidget"):  # type: ignore
+    def __init__(self, svg_widget: "QtSvg.QSvgWidget"):
         self._widget = svg_widget
 
     def paint(self, printer: QPrinter) -> None:
         """Scale vector graphic to match printer page and paint using render."""
         _logger.debug("Painting vector graphic for print")
+        page_rect = printer.pageRect(printer.Unit.DevicePixel).toRect()
         scale = min(
-            self._widget.sizeHint().width() / printer.pageRect().width(),
-            self._widget.sizeHint().height() / printer.pageRect().height(),
+            self._widget.sizeHint().width() / page_rect.width(),
+            self._widget.sizeHint().height() / page_rect.height(),
         )
         self._widget.setFixedSize(self._widget.sizeHint() * scale)
         self._widget.render(printer)
@@ -176,8 +185,9 @@ def paint(self, printer: QPrinter) -> None:
                 printer.newPage()
 
             self._widget.jumpToFrame(frame)
+            page_size = printer.pageRect(printer.Unit.DevicePixel).toRect().size()
             pixmap = self._widget.currentPixmap().scaled(
-                printer.pageRect().size(), Qt.KeepAspectRatio
+                page_size, Qt.AspectRatioMode.KeepAspectRatio
             )
             painter.drawPixmap(0, 0, pixmap)
 
diff --git a/vimiv/qt/__init__.py b/vimiv/qt/__init__.py
new file mode 100644
index 000000000..f68b15fd0
--- /dev/null
+++ b/vimiv/qt/__init__.py
@@ -0,0 +1,76 @@
+# vim: ft=python fileencoding=utf-8 sw=4 et sts=4
+
+# This file is part of vimiv.
+# Copyright 2017-2023 Christian Karl (karlch) <karlch at protonmail dot com>
+# License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details.
+
+"""Wrapper module around qt to support various python wrapper versions."""
+
+import importlib
+import enum
+import os
+
+
+class WRAPPERS(enum.Enum):
+    """List of supported python Qt wrappers.
+
+    Order matters as this is the order in which we check for them being installed if the
+    wrapper is not defined by the user explicitly.
+    """
+
+    PyQt5 = "PyQt5"
+    PyQt6 = "PyQt6"
+    PySide6 = "PySide6"
+
+
+_WRAPPERS_PYQT = [WRAPPERS.PyQt5, WRAPPERS.PyQt6]
+_WRAPPER_NAMES = [wrapper.value for wrapper in WRAPPERS]
+_WRAPPER_NAMES_STR = ", ".join(_WRAPPER_NAMES)
+
+WRAPPER = None
+
+
+def _select_wrapper():
+    """Select a python Qt wrapper version based on environment variable and fallback."""
+    env_var = "VIMIV_QT_WRAPPER"
+    env_wrapper = os.environ.get(env_var)
+
+    if env_wrapper is None:
+        return _autoselect_wrapper()
+
+    if env_wrapper not in _WRAPPER_NAMES:
+        raise ImportError(
+            f"Unknown wrapper {env_wrapper} set via environment variable {env_var}.\n"
+            f"Valid options: {_WRAPPER_NAMES_STR}"
+        )
+
+    try:
+        importlib.import_module(env_wrapper)
+    except ImportError:
+        raise ImportError(
+            f"Could not import {env_wrapper} set via environment variable {env_var}.\n"
+            f"Please install {env_wrapper} or use a different wrapper."
+        )
+
+    return WRAPPERS(env_wrapper)
+
+
+def _autoselect_wrapper():
+    """Select a python Qt wrapper version based on availability and enum order.
+
+    We do not auto-detect PySide6 yet, as there are still multiple issues.
+    """
+    for wrapper in _WRAPPERS_PYQT:
+        try:
+            importlib.import_module(wrapper.value)
+            return wrapper
+        except ImportError:  # Raise later
+            pass
+    raise ImportError(f"No valid Qt wrapper found. Valid options: {_WRAPPER_NAMES_STR}")
+
+
+WRAPPER = _select_wrapper()
+
+USE_PYQT5 = WRAPPER == WRAPPERS.PyQt5
+USE_PYQT6 = WRAPPER == WRAPPERS.PyQt6
+USE_PYSIDE6 = WRAPPER == WRAPPERS.PySide6
diff --git a/vimiv/qt/core.py b/vimiv/qt/core.py
new file mode 100644
index 000000000..bd4003b2b
--- /dev/null
+++ b/vimiv/qt/core.py
@@ -0,0 +1,34 @@
+# vim: ft=python fileencoding=utf-8 sw=4 et sts=4
+
+# This file is part of vimiv.
+# Copyright 2017-2023 Christian Karl (karlch) <karlch at protonmail dot com>
+# License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details.
+
+# pylint: disable=missing-module-docstring,wildcard-import,unused-wildcard-import
+
+from vimiv import qt
+
+
+if qt.USE_PYQT5:
+    from PyQt5.QtCore import *
+elif qt.USE_PYQT6:
+    from PyQt6.QtCore import *
+elif qt.USE_PYSIDE6:
+    # pylint: disable=no-name-in-module,undefined-variable
+    from PySide6.QtCore import *
+    from PySide6.QtCore import __version__ as PYQT_VERSION_STR
+
+    pyqtSignal = Signal
+    pyqtBoundSignal = SignalInstance
+    pyqtSlot = Slot
+    QT_VERSION_STR = qVersion()
+
+
+class Align:
+    """Namespace for easier access to the Qt alignment flags."""
+
+    Center = Qt.AlignmentFlag.AlignCenter
+    Left = Qt.AlignmentFlag.AlignLeft
+    Right = Qt.AlignmentFlag.AlignRight
+    Top = Qt.AlignmentFlag.AlignTop
+    Bottom = Qt.AlignmentFlag.AlignBottom
diff --git a/vimiv/qt/gui.py b/vimiv/qt/gui.py
new file mode 100644
index 000000000..c0e92bae6
--- /dev/null
+++ b/vimiv/qt/gui.py
@@ -0,0 +1,17 @@
+# vim: ft=python fileencoding=utf-8 sw=4 et sts=4
+
+# This file is part of vimiv.
+# Copyright 2017-2023 Christian Karl (karlch) <karlch at protonmail dot com>
+# License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details.
+
+# pylint: disable=missing-module-docstring,wildcard-import,unused-wildcard-import
+
+from vimiv import qt
+
+
+if qt.USE_PYQT5:
+    from PyQt5.QtGui import *
+elif qt.USE_PYQT6:
+    from PyQt6.QtGui import *
+elif qt.USE_PYSIDE6:
+    from PySide6.QtGui import *
diff --git a/vimiv/qt/printsupport.py b/vimiv/qt/printsupport.py
new file mode 100644
index 000000000..9743f7630
--- /dev/null
+++ b/vimiv/qt/printsupport.py
@@ -0,0 +1,24 @@
+# vim: ft=python fileencoding=utf-8 sw=4 et sts=4
+
+# This file is part of vimiv.
+# Copyright 2017-2023 Christian Karl (karlch) <karlch at protonmail dot com>
+# License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details.
+
+# pylint: disable=missing-module-docstring,wildcard-import,unused-wildcard-import
+
+from vimiv import qt
+from vimiv.qt import gui
+
+
+if qt.USE_PYQT5:
+    from PyQt5.QtPrintSupport import *
+
+    Orientation = QPrinter.Orientation
+elif qt.USE_PYQT6:
+    from PyQt6.QtPrintSupport import *
+
+    Orientation = gui.QPageLayout.Orientation
+elif qt.USE_PYSIDE6:
+    from PySide6.QtPrintSupport import *
+
+    Orientation = gui.QPageLayout.Orientation
diff --git a/vimiv/qt/svg.py b/vimiv/qt/svg.py
new file mode 100644
index 000000000..e6d12e4c0
--- /dev/null
+++ b/vimiv/qt/svg.py
@@ -0,0 +1,18 @@
+# vim: ft=python fileencoding=utf-8 sw=4 et sts=4
+
+# This file is part of vimiv.
+# Copyright 2017-2023 Christian Karl (karlch) <karlch at protonmail dot com>
+# License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details.
+
+# pylint: disable=missing-module-docstring
+
+from vimiv import qt
+from vimiv.utils import lazy
+
+# Lazy import the module that implements QSvgWidget
+if qt.USE_PYQT5:
+    QtSvg = lazy.import_module("PyQt5.QtSvg", optional=True)
+elif qt.USE_PYQT6:
+    QtSvg = lazy.import_module("PyQt6.QtSvgWidgets", optional=True)
+elif qt.USE_PYSIDE6:
+    QtSvg = lazy.import_module("PySide6.QtSvgWidgets", optional=True)
diff --git a/vimiv/qt/widgets.py b/vimiv/qt/widgets.py
new file mode 100644
index 000000000..63ee9b652
--- /dev/null
+++ b/vimiv/qt/widgets.py
@@ -0,0 +1,17 @@
+# vim: ft=python fileencoding=utf-8 sw=4 et sts=4
+
+# This file is part of vimiv.
+# Copyright 2017-2023 Christian Karl (karlch) <karlch at protonmail dot com>
+# License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details.
+
+# pylint: disable=missing-module-docstring,wildcard-import,unused-wildcard-import
+
+from vimiv import qt
+
+
+if qt.USE_PYQT5:
+    from PyQt5.QtWidgets import *
+if qt.USE_PYQT6:
+    from PyQt6.QtWidgets import *
+elif qt.USE_PYSIDE6:
+    from PySide6.QtWidgets import *
diff --git a/vimiv/startup.py b/vimiv/startup.py
index 3aa6c5180..4a681b4e9 100644
--- a/vimiv/startup.py
+++ b/vimiv/startup.py
@@ -17,8 +17,8 @@
 import tempfile
 from typing import cast, List
 
-from PyQt5.QtCore import QSize, QCoreApplication
-from PyQt5.QtWidgets import QApplication
+from vimiv.qt.core import QSize, QCoreApplication
+from vimiv.qt.widgets import QApplication
 
 from vimiv import app, api, parser, imutils, plugins
 from vimiv.commands import runners, search, wildcards
@@ -47,7 +47,7 @@ def main() -> int:
     crash_handler.CrashHandler(qapp)
     setup_post_app(args)
     _logger.debug("Startup completed, starting Qt main loop")
-    returncode = qapp.exec_()
+    returncode = qapp.exec()
     plugins.cleanup()
     _logger.debug("Exiting with status %d", returncode)
     return returncode
@@ -144,7 +144,7 @@ def init_ui(args: argparse.Namespace) -> None:
     if args.fullscreen:
         mw.fullscreen()
     # Center on screen and apply size
-    screen_geometry = QApplication.desktop().screenGeometry()
+    screen_geometry = QApplication.primaryScreen().geometry()
     geometry = (
         args.geometry
         if args.geometry
diff --git a/vimiv/utils/__init__.py b/vimiv/utils/__init__.py
index 41a5f7eba..3f004abbb 100644
--- a/vimiv/utils/__init__.py
+++ b/vimiv/utils/__init__.py
@@ -6,23 +6,16 @@
 
 """Various utility functions."""
 
-import abc
 import functools
 import inspect
 import re
 import typing
 
-from PyQt5.QtCore import Qt, pyqtSlot, QRunnable, QThreadPool, QProcess, QTimer
-from PyQt5.QtGui import QPixmap, QColor, QPainter
+from vimiv.qt.core import Qt, pyqtSlot, QRunnable, QThreadPool, QProcess, QTimer
+from vimiv.qt.gui import QPixmap, QColor, QPainter
 
 from vimiv.utils.customtypes import AnyT, FuncT, FuncNoneT, NumberT
 
-# Different location under PyQt < 5.11
-try:
-    from PyQt5.sip import wrappertype
-except ImportError:  # pragma: no cover  # Covered in a different tox env during CI
-    from sip import wrappertype  # type: ignore
-
 
 RE_STR_NOT_ESCAPED = r"(?<!\\)"
 RE_STR_ESCAPED = r"\\"
@@ -205,7 +198,7 @@ def function(self, x: int, y: int) -> None:
     annotations = typing.get_type_hints(function)
     args = _slot_args(function, annotations)
     kwargs = _slot_kwargs(annotations)
-    return pyqtSlot(*args, **kwargs)(function)  # type: ignore[return-value]
+    return pyqtSlot(*args, **kwargs)(function)  # type: ignore[return-value,unused-ignore]
 
 
 def _slot_args(function, annotations):
@@ -400,7 +393,7 @@ def create_pixmap(
     # Initialize
     pixmap = QPixmap(size, size)
     painter = QPainter(pixmap)
-    painter.setPen(Qt.NoPen)
+    painter.setPen(Qt.PenStyle.NoPen)
     # Frame
     painter.setBrush(QColor(frame_color))
     painter.drawRect(pixmap.rect())
@@ -434,7 +427,10 @@ def run_qprocess(cmd: str, *args: str, cwd=None) -> str:
     process.start(cmd, args)
     if not process.waitForFinished():
         raise OSError("Error waiting for process")
-    if process.exitStatus() != QProcess.NormalExit or process.exitCode() != 0:
+    if (
+        process.exitStatus() != QProcess.ExitStatus.NormalExit
+        or process.exitCode() != 0
+    ):
         stderr = qbytearray_to_str(process.readAllStandardError()).strip()
         raise OSError(stderr)
     return qbytearray_to_str(process.readAllStandardOutput()).strip()
@@ -456,10 +452,6 @@ def type_of_optional(typ: typing.Type) -> typing.Any:
     raise TypeError(f"{typ} is not of Optional type")
 
 
-class AbstractQObjectMeta(wrappertype, abc.ABCMeta):
-    """Metaclass to allow setting to be an ABC as well as a QObject."""
-
-
 def throttled(*, delay_ms: int):
     """Decorator to throttle function and only execute the final call after delay_ms.
 
diff --git a/vimiv/utils/crash_handler.py b/vimiv/utils/crash_handler.py
index ea686c969..aa02840d4 100644
--- a/vimiv/utils/crash_handler.py
+++ b/vimiv/utils/crash_handler.py
@@ -13,8 +13,8 @@
 import types
 from typing import Callable, Optional, Type
 
-from PyQt5.QtCore import QTimer, QSocketNotifier, QObject
-from PyQt5.QtWidgets import QApplication
+from vimiv.qt.core import QTimer, QSocketNotifier, QObject
+from vimiv.qt.widgets import QApplication
 
 from vimiv.utils import log, customtypes
 
@@ -67,7 +67,9 @@ def _setup_wakeup_fd(self) -> None:
         for fd in (read_fd, write_fd):
             flags = fcntl.fcntl(fd, fcntl.F_GETFL)
             fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
-        notifier = QSocketNotifier(read_fd, QSocketNotifier.Read, self)  # type: ignore
+        notifier = QSocketNotifier(
+            read_fd, QSocketNotifier.Type.Read, self  # type: ignore
+        )
         notifier.activated.connect(lambda: None)
         signal.set_wakeup_fd(write_fd)
 
diff --git a/vimiv/utils/files.py b/vimiv/utils/files.py
index 68702e529..a76cf4af6 100644
--- a/vimiv/utils/files.py
+++ b/vimiv/utils/files.py
@@ -11,7 +11,7 @@
 import os
 from typing import List, Tuple, Optional, BinaryIO, Iterable, Callable
 
-from PyQt5.QtGui import QImageReader
+from vimiv.qt.gui import QImageReader
 
 from vimiv.utils import imagereader
 
diff --git a/vimiv/utils/imagereader.py b/vimiv/utils/imagereader.py
index ba7f9aca8..565748f4a 100644
--- a/vimiv/utils/imagereader.py
+++ b/vimiv/utils/imagereader.py
@@ -9,8 +9,8 @@
 import abc
 from typing import Dict, Callable
 
-from PyQt5.QtCore import Qt
-from PyQt5.QtGui import QImageReader, QPixmap, QImage
+from vimiv.qt.core import Qt
+from vimiv.qt.gui import QImageReader, QPixmap, QImage
 
 from .files import imghdr
 
@@ -45,7 +45,9 @@ def get_pixmap(self) -> QPixmap:
 
     def get_image(self, size: int) -> QImage:
         """Read self.path from disk and return a scaled QImage."""
-        pixmap = self.get_pixmap().scaled(size, size, Qt.KeepAspectRatio)
+        pixmap = self.get_pixmap().scaled(
+            size, size, Qt.AspectRatioMode.KeepAspectRatio
+        )
         return pixmap.toImage()
 
     @classmethod
@@ -59,7 +61,7 @@ class QtReader(BaseReader):
 
     def __init__(self, path: str, file_format: str):
         super().__init__(path, file_format)
-        self._handler = QImageReader(path, file_format.encode())
+        self._handler = QImageReader(path, file_format.encode())  # type: ignore[call-overload,unused-ignore]
         self._handler.setAutoTransform(True)
         if not self._handler.canRead():
             # TODO
@@ -76,7 +78,7 @@ def is_animation(self) -> bool:
     def get_pixmap(self) -> QPixmap:
         """Retrieve the pixmap directly from the image reader."""
         pixmap = QPixmap.fromImageReader(self._handler)
-        if self._handler.error():
+        if pixmap.isNull():
             raise ValueError(
                 f"Error reading image '{self.path}': {self._handler.errorString()}"
             )
@@ -85,7 +87,7 @@ def get_pixmap(self) -> QPixmap:
     def get_image(self, size: int) -> QImage:
         """Retrieve the down-scaled image directly from the image reader."""
         qsize = self._handler.size()
-        qsize.scale(size, size, Qt.KeepAspectRatio)
+        qsize.scale(size, size, Qt.AspectRatioMode.KeepAspectRatio)
         self._handler.setScaledSize(qsize)
         return self._handler.read()
 
diff --git a/vimiv/utils/log.py b/vimiv/utils/log.py
index 6bd887276..e80fb8b1e 100644
--- a/vimiv/utils/log.py
+++ b/vimiv/utils/log.py
@@ -47,7 +47,7 @@
 import logging
 from typing import Dict, List, Optional, Any, Set
 
-from PyQt5.QtCore import pyqtSignal, QObject
+from vimiv.qt.core import pyqtSignal, QObject
 
 import vimiv
 
diff --git a/vimiv/utils/migration.py b/vimiv/utils/migration.py
index fcc38937d..9af9aa5bf 100644
--- a/vimiv/utils/migration.py
+++ b/vimiv/utils/migration.py
@@ -10,7 +10,7 @@
 import shutil
 from typing import NamedTuple, Optional, List
 
-from PyQt5.QtWidgets import QLabel, QVBoxLayout, QWidget
+from vimiv.qt.widgets import QLabel, QVBoxLayout, QWidget
 
 import vimiv
 from vimiv.utils import xdg
diff --git a/vimiv/utils/thumbnail_manager.py b/vimiv/utils/thumbnail_manager.py
index a547f0f93..ccd43a76b 100644
--- a/vimiv/utils/thumbnail_manager.py
+++ b/vimiv/utils/thumbnail_manager.py
@@ -18,8 +18,8 @@
 import tempfile
 from typing import Dict, List
 
-from PyQt5.QtCore import QRunnable, pyqtSignal, QObject
-from PyQt5.QtGui import QIcon, QPixmap, QImage
+from vimiv.qt.core import QRunnable, pyqtSignal, QObject
+from vimiv.qt.gui import QIcon, QPixmap, QImage
 
 import vimiv
 from vimiv.utils import xdg, imagereader, Pool
diff --git a/vimiv/utils/xdg.py b/vimiv/utils/xdg.py
index 4c675bc73..9d2e003cd 100644
--- a/vimiv/utils/xdg.py
+++ b/vimiv/utils/xdg.py
@@ -8,7 +8,7 @@
 
 import os
 
-from PyQt5.QtCore import QStandardPaths
+from vimiv.qt.core import QStandardPaths
 
 import vimiv
 
@@ -37,15 +37,21 @@ def makedirs(*paths: str) -> None:
 
 
 def user_data_dir(*paths: str) -> str:
-    return _standardpath(QStandardPaths.GenericDataLocation, "data", *paths)
+    return _standardpath(
+        QStandardPaths.StandardLocation.GenericDataLocation, "data", *paths
+    )
 
 
 def user_config_dir(*paths: str) -> str:
-    return _standardpath(QStandardPaths.GenericConfigLocation, "config", *paths)
+    return _standardpath(
+        QStandardPaths.StandardLocation.GenericConfigLocation, "config", *paths
+    )
 
 
 def user_cache_dir(*paths: str) -> str:
-    return _standardpath(QStandardPaths.GenericCacheLocation, "cache", *paths)
+    return _standardpath(
+        QStandardPaths.StandardLocation.GenericCacheLocation, "cache", *paths
+    )
 
 
 def vimiv_data_dir(*paths: str) -> str:
diff --git a/vimiv/version.py b/vimiv/version.py
index 814440288..6c6365276 100644
--- a/vimiv/version.py
+++ b/vimiv/version.py
@@ -15,13 +15,12 @@
 import sys
 from typing import Optional
 
-from PyQt5.QtCore import QT_VERSION_STR, PYQT_VERSION_STR
+from vimiv.qt.core import QT_VERSION_STR, PYQT_VERSION_STR
 
 import vimiv
+from vimiv.qt.svg import QtSvg
 from vimiv.imutils import exif
-from vimiv.utils import xdg, run_qprocess, lazy
-
-QtSvg = lazy.import_module("PyQt5.QtSvg", optional=True)
+from vimiv.utils import xdg, run_qprocess
 
 
 def info() -> str:
diff --git a/vimiv/widgets.py b/vimiv/widgets.py
index 06b24060c..fc748ee2b 100644
--- a/vimiv/widgets.py
+++ b/vimiv/widgets.py
@@ -8,9 +8,9 @@
 
 from typing import Optional, Tuple
 
-from PyQt5.QtCore import Qt, QTimer
-from PyQt5.QtGui import QPainter, QFontMetrics
-from PyQt5.QtWidgets import QTreeView, QAbstractItemView, QSlider, QDialog
+from vimiv.qt.core import Qt, QTimer
+from vimiv.qt.gui import QPainter, QFontMetrics
+from vimiv.qt.widgets import QTreeView, QAbstractItemView, QSlider, QDialog
 
 from vimiv.config import styles
 from vimiv.utils import cached_method
@@ -20,7 +20,7 @@ class ScrollToCenterMixin:
     """Mixin class to ensure the selected index stays at the center when scrolling."""
 
     def scrollTo(self, index, _hint=None):
-        super().scrollTo(index, self.PositionAtCenter)
+        super().scrollTo(index, self.ScrollHint.PositionAtCenter)
 
 
 class ScrollWheelCumulativeMixin:
@@ -100,8 +100,8 @@ class FlatTreeView(ScrollToCenterMixin, QTreeView):
     def __init__(self, parent=None):
         super().__init__(parent=parent)
 
-        self.setSelectionBehavior(QAbstractItemView.SelectRows)
-        self.setSelectionMode(QAbstractItemView.SingleSelection)
+        self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
+        self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
 
         self.setUniformRowHeights(True)
         self.setIndentation(0)
@@ -109,7 +109,7 @@ def __init__(self, parent=None):
         self.setAlternatingRowColors(True)
         self.setItemsExpandable(False)
         self.setExpandsOnDoubleClick(False)
-        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+        self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
 
     def _select_row(self, row):
         """Select a specific row in the library.
@@ -163,7 +163,7 @@ def paintEvent(self, event):
         painter = QPainter(self)
         font_metrics = QFontMetrics(self.font())
 
-        x = (rect.width() - font_metrics.width(text)) // 2
+        x = (rect.width() - font_metrics.horizontalAdvance(text)) // 2
         y = rect.height() - (rect.height() - font_metrics.capHeight()) // 2
         painter.drawText(x, y, text)
 
@@ -226,7 +226,7 @@ class PopUp(QDialog):
     def __init__(self, title: str, parent=None):
         super().__init__(parent=parent)
         self.setWindowTitle(title)
-        self.setWindowFlags(self.windowFlags() | Qt.Tool)
+        self.setWindowFlags(self.windowFlags() | Qt.WindowType.Tool)
         styles.apply(self)
 
     def reject(self):


Test patch:
diff --git a/tests/end2end/conftest.py b/tests/end2end/conftest.py
index 193103a76..c2af6433f 100644
--- a/tests/end2end/conftest.py
+++ b/tests/end2end/conftest.py
@@ -8,13 +8,13 @@
 
 import logging
 
-from PyQt5.QtGui import QPixmap
-
 import pytest
 import pytest_bdd as bdd
 
 import mockdecorators
 
+from vimiv.qt.gui import QPixmap
+
 with mockdecorators.apply():
     from vimiv import api, startup, utils
     from vimiv.commands import runners
diff --git a/tests/end2end/features/conftest.py b/tests/end2end/features/conftest.py
index ff4f3f2f2..1e0c06c5e 100644
--- a/tests/end2end/features/conftest.py
+++ b/tests/end2end/features/conftest.py
@@ -9,13 +9,13 @@
 import os
 import pathlib
 
-from PyQt5.QtCore import Qt, QProcess, QTimer
-from PyQt5.QtGui import QFocusEvent, QMouseEvent
-from PyQt5.QtWidgets import QApplication
-
 import pytest
 import pytest_bdd as bdd
 
+from vimiv.qt.core import Qt, QProcess, QTimer, QPointF
+from vimiv.qt.gui import QFocusEvent, QMouseEvent
+from vimiv.qt.widgets import QApplication
+
 import vimiv.gui.library
 import vimiv.gui.thumbnail
 import vimiv.gui.mainwindow
@@ -124,10 +124,10 @@ def get_prompt():
 
     def function(key):
         keys = {
-            "y": Qt.Key_Y,
-            "n": Qt.Key_N,
-            "<return>": Qt.Key_Return,
-            "<escape>": Qt.Key_Escape,
+            "y": Qt.Key.Key_Y,
+            "n": Qt.Key.Key_N,
+            "<return>": Qt.Key.Key_Return,
+            "<escape>": Qt.Key.Key_Escape,
         }
         try:
             qkey = keys[key]
@@ -149,22 +149,22 @@ def click_prompt_key():
 def keypress(qtbot):
     """Fixture to press keys on a widget handling special keys appropriately."""
     special_keys = {
-        "<escape>": Qt.Key_Escape,
-        "<return>": Qt.Key_Return,
-        "<space>": Qt.Key_Space,
-        "<backspace>": Qt.Key_Backspace,
+        "<escape>": Qt.Key.Key_Escape,
+        "<return>": Qt.Key.Key_Return,
+        "<space>": Qt.Key.Key_Space,
+        "<backspace>": Qt.Key.Key_Backspace,
     }
 
     def get_modifier(keys):
         modifiers = {
-            "<ctrl>": Qt.ControlModifier,
-            "<alt>": Qt.AltModifier,
-            "<shift>": Qt.ShiftModifier,
+            "<ctrl>": Qt.KeyboardModifier.ControlModifier,
+            "<alt>": Qt.KeyboardModifier.AltModifier,
+            "<shift>": Qt.KeyboardModifier.ShiftModifier,
         }
         for name, key in modifiers.items():
             if keys.startswith(name):
                 return key, keys.replace(name, "")
-        return Qt.NoModifier, keys
+        return Qt.KeyboardModifier.NoModifier, keys
 
     def press_impl(widget, keys):
         modifier, keys = get_modifier(keys)
@@ -187,22 +187,22 @@ def mousedrag(qtbot):
     def drag(widget, *, start, diff):
         end = start + diff
 
-        qtbot.mousePress(widget, Qt.LeftButton, pos=start)
+        qtbot.mousePress(widget, Qt.MouseButton.LeftButton, pos=start)
 
         global_end = widget.mapToGlobal(end)
-        button = buttons = Qt.NoButton
+        button = buttons = Qt.MouseButton.NoButton
         move_event = QMouseEvent(
-            QMouseEvent.MouseMove,
-            end,
-            global_end,
-            global_end,
+            QMouseEvent.Type.MouseMove,
+            QPointF(end),
+            QPointF(global_end),
+            QPointF(global_end),
             button,
             buttons,
-            Qt.NoModifier,
+            Qt.KeyboardModifier.NoModifier,
         )
         QApplication.sendEvent(widget, move_event)
 
-        qtbot.mouseRelease(widget, Qt.LeftButton, pos=end)
+        qtbot.mouseRelease(widget, Qt.MouseButton.LeftButton, pos=end)
 
     return drag
 
@@ -222,7 +222,9 @@ def run_command(command, qtbot):
 
         def external_finished():
             state = external_runner.state()
-            assert state == QProcess.NotRunning, "external command timed out"
+            assert (
+                state == QProcess.ProcessState.NotRunning
+            ), "external command timed out"
 
         qtbot.waitUntil(external_finished, timeout=30000)
 
@@ -268,7 +270,7 @@ def focus_widget(image, library, widget_name):
         raise KeyError(
             f"Unknown widget '{widget_name}'. Currently supported: {', '.join(names)}"
         )
-    event = QFocusEvent(QFocusEvent.FocusOut)
+    event = QFocusEvent(QFocusEvent.Type.FocusOut)
     widget.focusOutEvent(event)
 
 
diff --git a/tests/end2end/features/edit/test_crop_bdd.py b/tests/end2end/features/edit/test_crop_bdd.py
index 4ab2f0f97..4c92dd40d 100644
--- a/tests/end2end/features/edit/test_crop_bdd.py
+++ b/tests/end2end/features/edit/test_crop_bdd.py
@@ -6,13 +6,12 @@
 
 import re
 
-from PyQt5.QtCore import Qt, QPoint
-from PyQt5.QtWidgets import QApplication
-
 import pytest
 import pytest_bdd as bdd
 
 import vimiv.gui.crop_widget
+from vimiv.qt.core import Qt, QPoint
+from vimiv.qt.widgets import QApplication
 
 
 bdd.scenarios("crop.feature")
diff --git a/tests/end2end/features/image/test_gif_bdd.py b/tests/end2end/features/image/test_gif_bdd.py
index 82f74a587..2387004f8 100644
--- a/tests/end2end/features/image/test_gif_bdd.py
+++ b/tests/end2end/features/image/test_gif_bdd.py
@@ -20,9 +20,9 @@ def movie(image):
 
 @bdd.then("the animation should be playing")
 def check_animation_playing(movie):
-    assert movie.state() == movie.Running
+    assert movie.state() == movie.MovieState.Running
 
 
 @bdd.then("the animation should be paused")
 def check_animation_paused(movie):
-    assert movie.state() == movie.Paused
+    assert movie.state() == movie.MovieState.Paused
diff --git a/tests/end2end/features/image/test_imagescroll_bdd.py b/tests/end2end/features/image/test_imagescroll_bdd.py
index 75a21da19..b1d96f8b7 100644
--- a/tests/end2end/features/image/test_imagescroll_bdd.py
+++ b/tests/end2end/features/image/test_imagescroll_bdd.py
@@ -4,10 +4,10 @@
 # Copyright 2017-2023 Christian Karl (karlch) <karlch at protonmail dot com>
 # License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details.
 
-from PyQt5.QtGui import QResizeEvent
-
 import pytest_bdd as bdd
 
+from vimiv.qt.gui import QResizeEvent
+
 
 bdd.scenarios("imagescroll.feature")
 
diff --git a/tests/end2end/features/misc/conftest.py b/tests/end2end/features/misc/conftest.py
index bff47c87b..2d0850492 100644
--- a/tests/end2end/features/misc/conftest.py
+++ b/tests/end2end/features/misc/conftest.py
@@ -4,11 +4,11 @@
 # Copyright 2017-2023 Christian Karl (karlch) <karlch at protonmail dot com>
 # License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details.
 
-from PyQt5.QtGui import QGuiApplication, QClipboard
-
 import pytest
 import pytest_bdd as bdd
 
+from vimiv.qt.gui import QGuiApplication, QClipboard
+
 
 @pytest.fixture()
 def clipboard():
@@ -17,39 +17,41 @@ def clipboard():
 
 @bdd.then(bdd.parsers.parse("The clipboard should contain '{text}'"))
 def check_clipboard(clipboard, text):
-    assert text in clipboard.text(mode=QClipboard.Clipboard)
+    assert text in clipboard.text(mode=QClipboard.Mode.Clipboard)
 
 
 @bdd.then(bdd.parsers.parse("The primary selection should contain '{text}'"))
 def check_primary(clipboard, text):
-    assert text in clipboard.text(mode=QClipboard.Selection)
+    assert text in clipboard.text(mode=QClipboard.Mode.Selection)
 
 
 @bdd.then(bdd.parsers.parse("The clipboard should contain any image"))
 def check_clipboard_image(clipboard, image):
-    assert not clipboard.pixmap(mode=QClipboard.Clipboard).toImage().isNull()
+    assert not clipboard.pixmap(mode=QClipboard.Mode.Clipboard).toImage().isNull()
 
 
 @bdd.then(bdd.parsers.parse("The primary selection should contain any image"))
 def check_primary_image(clipboard, image):
-    assert not clipboard.pixmap(mode=QClipboard.Selection).toImage().isNull()
+    assert not clipboard.pixmap(mode=QClipboard.Mode.Selection).toImage().isNull()
 
 
 @bdd.then(bdd.parsers.parse("The clipboard should contain an image with width {width}"))
 def check_clipboard_image_width(clipboard, width):
-    assert clipboard.pixmap(mode=QClipboard.Clipboard).size().width() == int(width)
+    assert clipboard.pixmap(mode=QClipboard.Mode.Clipboard).size().width() == int(width)
 
 
 @bdd.then(
     bdd.parsers.parse("The clipboard should contain an image with height {height}")
 )
 def check_clipboard_image_height(clipboard, height):
-    assert clipboard.pixmap(mode=QClipboard.Clipboard).size().height() == int(height)
+    assert clipboard.pixmap(mode=QClipboard.Mode.Clipboard).size().height() == int(
+        height
+    )
 
 
 @bdd.then(bdd.parsers.parse("The clipboard should contain an image with size {size}"))
 def check_clipboard_image_size(clipboard, size):
     assert max(
-        clipboard.pixmap(mode=QClipboard.Clipboard).size().height(),
-        clipboard.pixmap(mode=QClipboard.Clipboard).size().width(),
+        clipboard.pixmap(mode=QClipboard.Mode.Clipboard).size().height(),
+        clipboard.pixmap(mode=QClipboard.Mode.Clipboard).size().width(),
     ) == int(size)
diff --git a/tests/end2end/features/misc/test_clipboard_bdd.py b/tests/end2end/features/misc/test_clipboard_bdd.py
index 91adc4e50..7f95ec4ba 100644
--- a/tests/end2end/features/misc/test_clipboard_bdd.py
+++ b/tests/end2end/features/misc/test_clipboard_bdd.py
@@ -6,10 +6,10 @@
 
 import os
 
-from PyQt5.QtGui import QClipboard
-
 import pytest_bdd as bdd
 
+from vimiv.qt.gui import QClipboard
+
 
 bdd.scenarios("clipboard.feature")
 
@@ -23,4 +23,4 @@
 )
 def check_clipboard_abspath(clipboard, text):
     text = os.path.abspath(text)
-    assert clipboard.text(mode=QClipboard.Clipboard) == text
+    assert clipboard.text(mode=QClipboard.Mode.Clipboard) == text
diff --git a/tests/end2end/features/misc/test_symlink_bdd.py b/tests/end2end/features/misc/test_symlink_bdd.py
index 0d86f37ac..969f395bc 100644
--- a/tests/end2end/features/misc/test_symlink_bdd.py
+++ b/tests/end2end/features/misc/test_symlink_bdd.py
@@ -4,10 +4,10 @@
 # Copyright 2017-2023 Christian Karl (karlch) <karlch at protonmail dot com>
 # License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details.
 
-from PyQt5.QtGui import QPixmap
-
 import pytest_bdd as bdd
 
+from vimiv.qt.gui import QPixmap
+
 from vimiv import startup
 
 
diff --git a/tests/integration/test_edit.py b/tests/integration/test_edit.py
index abc7b29be..e9f84bda4 100644
--- a/tests/integration/test_edit.py
+++ b/tests/integration/test_edit.py
@@ -6,10 +6,10 @@
 
 """Integration tests related to editing images."""
 
-from PyQt5.QtGui import QPixmap, QColor
-
 import pytest
 
+from vimiv.qt.gui import QPixmap, QColor
+
 from vimiv.config import styles
 from vimiv.imutils import edit_handler
 
diff --git a/tests/unit/api/test_prompt.py b/tests/unit/api/test_prompt.py
index a8e30778e..9ca549050 100644
--- a/tests/unit/api/test_prompt.py
+++ b/tests/unit/api/test_prompt.py
@@ -6,10 +6,10 @@
 
 """Tests for vimiv.api.prompt."""
 
-from PyQt5.QtCore import QObject
-
 import pytest
 
+from vimiv.qt.core import QObject
+
 from vimiv.api import prompt
 
 
diff --git a/tests/unit/gui/test_eventhandler.py b/tests/unit/gui/test_eventhandler.py
index eb6c8f0ef..4fc109465 100644
--- a/tests/unit/gui/test_eventhandler.py
+++ b/tests/unit/gui/test_eventhandler.py
@@ -6,11 +6,11 @@
 
 """Tests for vimiv.gui.eventhandler."""
 
-from PyQt5.QtCore import QEvent, Qt, QPoint
-from PyQt5.QtGui import QKeyEvent, QMouseEvent
-
 import pytest
 
+from vimiv.qt.core import QEvent, Qt, QPointF
+from vimiv.qt.gui import QKeyEvent, QMouseEvent
+
 from vimiv.gui import eventhandler
 
 
@@ -43,37 +43,55 @@ def test_temp_key_storage_clears_text(storage, qtbot):
 @pytest.mark.parametrize(
     "qtkey, modifier, keyname, expected",
     [
-        (Qt.Key_A, Qt.NoModifier, "a", ("a",)),
-        (Qt.Key_A, Qt.ShiftModifier, "A", ("A",)),
-        (Qt.Key_Tab, Qt.NoModifier, "\t", ("<tab>",)),
-        (Qt.Key_Tab, Qt.ShiftModifier, "\t", ("<shift>", "<tab>")),
-        (Qt.Key_A, Qt.ControlModifier, "a", ("<ctrl>", "a")),
-        (Qt.Key_A, Qt.AltModifier, "a", ("<alt>", "a")),
-        (Qt.Key_A, Qt.AltModifier | Qt.ControlModifier, "a", ("<ctrl>", "<alt>", "a")),
-        (Qt.Key_Colon, Qt.NoModifier, ":", ("<colon>",)),
-        (Qt.Key_Equal, Qt.NoModifier, "=", ("<equal>",)),
+        (Qt.Key.Key_A, Qt.KeyboardModifier.NoModifier, "a", ("a",)),
+        (Qt.Key.Key_A, Qt.KeyboardModifier.ShiftModifier, "A", ("A",)),
+        (Qt.Key.Key_Tab, Qt.KeyboardModifier.NoModifier, "\t", ("<tab>",)),
+        (Qt.Key.Key_Tab, Qt.KeyboardModifier.ShiftModifier, "\t", ("<shift>", "<tab>")),
+        (Qt.Key.Key_A, Qt.KeyboardModifier.ControlModifier, "a", ("<ctrl>", "a")),
+        (Qt.Key.Key_A, Qt.KeyboardModifier.AltModifier, "a", ("<alt>", "a")),
+        (
+            Qt.Key.Key_A,
+            Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.ControlModifier,
+            "a",
+            ("<ctrl>", "<alt>", "a"),
+        ),
+        (Qt.Key.Key_Colon, Qt.KeyboardModifier.NoModifier, ":", ("<colon>",)),
+        (Qt.Key.Key_Equal, Qt.KeyboardModifier.NoModifier, "=", ("<equal>",)),
     ],
 )
 def test_keyevent_to_sequence(qtkey, modifier, keyname, expected):
-    event = QKeyEvent(QEvent.KeyPress, qtkey, modifier, keyname)
+    event = QKeyEvent(QEvent.Type.KeyPress, qtkey, modifier, keyname)
     assert eventhandler.keyevent_to_sequence(event) == expected
 
 
 def test_keyevent_to_sequence_for_only_modifier():
     with pytest.raises(ValueError):
-        event = QKeyEvent(QEvent.KeyPress, Qt.Key_Shift, Qt.ShiftModifier, "")
+        event = QKeyEvent(
+            QEvent.Type.KeyPress,
+            Qt.Key.Key_Shift,
+            Qt.KeyboardModifier.ShiftModifier,
+            "",
+        )
         assert eventhandler.keyevent_to_sequence(event) == tuple()
 
 
 @pytest.mark.parametrize(
     "qtbutton, modifier, expected",
     [
-        (Qt.LeftButton, Qt.NoModifier, ("<button-left>",)),
-        (Qt.MiddleButton, Qt.NoModifier, ("<button-middle>",)),
-        (Qt.RightButton, Qt.NoModifier, ("<button-right>",)),
+        (Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, ("<button-left>",)),
+        (
+            Qt.MouseButton.MiddleButton,
+            Qt.KeyboardModifier.NoModifier,
+            ("<button-middle>",),
+        ),
+        (
+            Qt.MouseButton.RightButton,
+            Qt.KeyboardModifier.NoModifier,
+            ("<button-right>",),
+        ),
         (
-            Qt.LeftButton,
-            Qt.ControlModifier,
+            Qt.MouseButton.LeftButton,
+            Qt.KeyboardModifier.ControlModifier,
             (
                 "<ctrl>",
                 "<button-left>",
@@ -86,7 +104,12 @@ def test_mouse_event_to_sequence(qtbutton, modifier, expected):
     assert eventhandler.mouseevent_to_sequence(event) == expected
 
 
-def _create_mouse_event(button, modifier=Qt.NoModifier):
+def _create_mouse_event(button, modifier=Qt.KeyboardModifier.NoModifier):
     return QMouseEvent(
-        QEvent.MouseButtonPress, QPoint(0, 0), QPoint(0, 0), button, button, modifier
+        QEvent.Type.MouseButtonPress,
+        QPointF(0, 0),
+        QPointF(0, 0),
+        button,
+        button,
+        modifier,
     )
diff --git a/tests/unit/gui/test_thumbnail.py b/tests/unit/gui/test_thumbnail.py
index 9e885c3f3..bba7a428f 100644
--- a/tests/unit/gui/test_thumbnail.py
+++ b/tests/unit/gui/test_thumbnail.py
@@ -6,11 +6,11 @@
 
 """Tests for vimiv.gui.thumbnail."""
 
-from PyQt5.QtCore import QSize
-from PyQt5.QtGui import QIcon
-
 import pytest
 
+from vimiv.qt.core import QSize
+from vimiv.qt.gui import QIcon
+
 from vimiv.gui.thumbnail import ThumbnailItem
 
 
diff --git a/tests/unit/imutils/test_imtransform.py b/tests/unit/imutils/test_imtransform.py
index c065724b0..5f0802a16 100644
--- a/tests/unit/imutils/test_imtransform.py
+++ b/tests/unit/imutils/test_imtransform.py
@@ -8,10 +8,10 @@
 
 import functools
 
-from PyQt5.QtGui import QPixmap
-
 import pytest
 
+from vimiv.qt.gui import QPixmap
+
 from vimiv.imutils import current_pixmap, imtransform
 
 
diff --git a/tests/unit/test_checkversion.py b/tests/unit/test_checkversion.py
index 6063a20d7..f8e73beb2 100644
--- a/tests/unit/test_checkversion.py
+++ b/tests/unit/test_checkversion.py
@@ -11,6 +11,7 @@
 import pytest
 
 from vimiv import checkversion
+import vimiv.qt.core
 
 
 @pytest.mark.parametrize("version_info", ((1,), (2, 6), (3, 5, 900)))
@@ -31,7 +32,9 @@ def test_check_python_version(capsys, monkeypatch, version_info):
 @pytest.mark.parametrize("version_info", ((1,), (4, 6), (5, 8, 900)))
 def test_check_pyqt_version(capsys, monkeypatch, version_info):
     """Tests to ensure exit and error message on too low PyQt version."""
-    monkeypatch.setattr(checkversion, "PYQT_VERSION", version_info)
+    monkeypatch.setattr(
+        vimiv.qt.core, "PYQT_VERSION_STR", ".".join(str(i) for i in version_info)
+    )
 
     with pytest.raises(SystemExit, match=str(checkversion.ERR_CODE)):
         checkversion.check_pyqt_version()
@@ -41,18 +44,6 @@ def test_check_pyqt_version(capsys, monkeypatch, version_info):
     assert captured.err == expected
 
 
-def test_check_pyqt_available(capsys, monkeypatch):
-    """Test to ensure exit and error message if PyQt is not available."""
-    monkeypatch.setattr(checkversion, "PYQT_VERSION", None)
-
-    with pytest.raises(SystemExit, match=str(checkversion.ERR_CODE)):
-        checkversion.check_pyqt_version()
-
-    expected = "PyQt is required to run vimiv.\n"
-    captured = capsys.readouterr()
-    assert captured.err == expected
-
-
 def build_message(software, required, version):
     """Helper to create the expected error message on too low software version."""
     # pylint: disable=consider-using-f-string
diff --git a/tests/unit/test_parser.py b/tests/unit/test_parser.py
index 7a10337a6..ab4b3e852 100644
--- a/tests/unit/test_parser.py
+++ b/tests/unit/test_parser.py
@@ -10,10 +10,10 @@
 import logging
 import os
 
-from PyQt5.QtCore import QSize
-
 import pytest
 
+from vimiv.qt.core import QSize
+
 from vimiv import parser
 
 
diff --git a/tests/unit/utils/test_files.py b/tests/unit/utils/test_files.py
index d2d9df657..78225f344 100644
--- a/tests/unit/utils/test_files.py
+++ b/tests/unit/utils/test_files.py
@@ -11,10 +11,10 @@
 import os
 import tarfile
 
-from PyQt5.QtGui import QImageReader
-
 import pytest
 
+from vimiv.qt.gui import QImageReader
+
 from vimiv.utils import files
 
 
diff --git a/tests/unit/utils/test_thumbnail_manager.py b/tests/unit/utils/test_thumbnail_manager.py
index f7a155035..e94f18bf4 100644
--- a/tests/unit/utils/test_thumbnail_manager.py
+++ b/tests/unit/utils/test_thumbnail_manager.py
@@ -7,10 +7,10 @@
 import hashlib
 import os
 
-from PyQt5.QtGui import QPixmap
-
 import pytest
 
+from vimiv.qt.gui import QPixmap
+
 from vimiv.utils import thumbnail_manager
 
 
diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py
index 021eeaf62..1ee826ebc 100644
--- a/tests/unit/utils/test_utils.py
+++ b/tests/unit/utils/test_utils.py
@@ -11,10 +11,10 @@
 import typing
 from typing import get_type_hints
 
-from PyQt5.QtCore import pyqtSignal, QObject, QByteArray
-
 import pytest
 
+from vimiv.qt.core import pyqtSignal, QObject, QByteArray
+
 from vimiv import utils