diff --git a/doc/developers.rst b/doc/developers.rst
index 205864a22..e80e5f5c5 100644
--- a/doc/developers.rst
+++ b/doc/developers.rst
@@ -75,7 +75,9 @@ unofficial Ubuntu repository, which has our software packages available to downl
sudo add-apt-repository ppa:openshot.developers/libopenshot-daily
sudo apt-get update
sudo apt-get install openshot-qt \
+ build-essential \
cmake \
+ catch2 \
libx11-dev \
libasound2-dev \
libavcodec-dev \
@@ -99,6 +101,8 @@ unofficial Ubuntu repository, which has our software packages available to downl
libzmq3-dev \
pkg-config \
python3-dev \
+ python3-pyqt5.qtwebengine \
+ python3-zmq \
protobuf-compiler \
qtbase5-dev \
libqt5svg5-dev \
@@ -106,6 +110,36 @@ unofficial Ubuntu repository, which has our software packages available to downl
qtmultimedia5-dev \
swig
+OpenShot supports PyQt5, PyQt6, and PySide6. libopenshot must be built
+against the correct Qt version for the binding you are using
+(Qt5: PyQt5, Qt6: PyQt6 or PySide6).
+
+For PySide6 (Qt6) bindings, install these packages:
+
+.. code-block:: bash
+
+ sudo apt-get install \
+ python3-pyside6.qtcore \
+ python3-pyside6.qtgui \
+ python3-pyside6.qtwidgets \
+ python3-pyside6.qtstatemachine \
+ python3-pyside6.qtsvg \
+ python3-pyside6.qtwebenginecore \
+ python3-pyside6.qtwebenginewidgets \
+ python3-pyside6.qtwebchannel \
+ python3-pyside6.qtuitools \
+ shiboken6
+
+For PyQt6, install these packages:
+
+.. code-block:: bash
+
+ sudo apt-get install \
+ qt6-base-dev \
+ qt6-base-dev-tools \
+ qt6-tools-dev \
+ qt6-svg-dev
+
At this point, you should have all 3 OpenShot components source code cloned into local folders, the OpenShot
daily PPA installed, and all of the required development and runtime dependencies installed. This is a
great start, and we are now ready to start compiling some code!
diff --git a/src/classes/app.py b/src/classes/app.py
index 78d96616f..fccf729f9 100644
--- a/src/classes/app.py
+++ b/src/classes/app.py
@@ -34,8 +34,8 @@
import traceback
import json
-from PyQt5.QtCore import PYQT_VERSION_STR, QT_VERSION_STR, pyqtSlot
-from PyQt5.QtWidgets import QApplication, QMessageBox
+from qt_api import QT_API, QT_VERSION_STR, BINDING_VERSION_STR, Slot
+from qt_api import QApplication, QMessageBox
# Disable sandbox support for QtWebEngine (required on some Linux distros
# for the QtWebEngineWidgets to be rendered, otherwise no timeline is visible).
@@ -121,6 +121,12 @@ def __init__(self, *args, **kwargs):
# Log some basic system info
self.log = log
+ # Clear any stale override cursor (can suppress widget cursors in PyQt5)
+ try:
+ while QApplication.overrideCursor():
+ QApplication.restoreOverrideCursor()
+ except Exception:
+ pass
self.show_environment(info, openshot)
if self.mode != "unittest":
self.check_libopenshot_version(info, openshot)
@@ -169,8 +175,7 @@ def show_environment(self, info, openshot):
log.info("processor: %s" % platform.processor())
log.info("machine: %s" % platform.machine())
log.info("python version: %s" % platform.python_version())
- log.info("qt5 version: %s" % QT_VERSION_STR)
- log.info("pyqt5 version: %s" % PYQT_VERSION_STR)
+ log.info("qt binding: %s (Qt %s, binding %s)" % (QT_API, QT_VERSION_STR, BINDING_VERSION_STR))
# Look for frozen version info
version_path = os.path.join(info.PATH, "settings", "version.json")
@@ -335,10 +340,15 @@ def show_errors(self):
def _tr(self, message):
return self.translate("", message)
- @pyqtSlot()
+ @Slot()
def cleanup(self):
"""aboutToQuit signal handler for application exit"""
self.log.debug("Saving settings in app.cleanup")
+ if getattr(self, "window", None):
+ try:
+ self.window._shutdown()
+ except Exception:
+ self.log.warning("Window shutdown raised during app cleanup.", exc_info=1)
try:
self.settings.save()
except Exception:
diff --git a/src/classes/clipboard.py b/src/classes/clipboard.py
index a5769d74c..f45a2f9e1 100644
--- a/src/classes/clipboard.py
+++ b/src/classes/clipboard.py
@@ -27,7 +27,7 @@
import pickle
import json
-from PyQt5.QtCore import QMimeData
+from qt_api import QMimeData
from classes.query import QueryObject
diff --git a/src/classes/exporters/edl.py b/src/classes/exporters/edl.py
index 993835eb9..6868b1222 100644
--- a/src/classes/exporters/edl.py
+++ b/src/classes/exporters/edl.py
@@ -28,7 +28,7 @@
import os
from operator import itemgetter
-from PyQt5.QtWidgets import QFileDialog
+from qt_api import QFileDialog
from classes import info
from classes.app import get_app
diff --git a/src/classes/exporters/final_cut_pro.py b/src/classes/exporters/final_cut_pro.py
index e28efc6ef..b4503335d 100644
--- a/src/classes/exporters/final_cut_pro.py
+++ b/src/classes/exporters/final_cut_pro.py
@@ -34,7 +34,7 @@
from xml.dom import minidom
import openshot
-from PyQt5.QtWidgets import QFileDialog
+from qt_api import QFileDialog
from classes import info
from classes.app import get_app
diff --git a/src/classes/importers/edl.py b/src/classes/importers/edl.py
index a1361585a..1a61c3321 100644
--- a/src/classes/importers/edl.py
+++ b/src/classes/importers/edl.py
@@ -31,7 +31,7 @@
from operator import itemgetter
import openshot
-from PyQt5.QtWidgets import QFileDialog
+from qt_api import QFileDialog
from classes import info
from classes.app import get_app
diff --git a/src/classes/importers/final_cut_pro.py b/src/classes/importers/final_cut_pro.py
index d54b6b662..925df99e2 100644
--- a/src/classes/importers/final_cut_pro.py
+++ b/src/classes/importers/final_cut_pro.py
@@ -31,7 +31,7 @@
from xml.dom import minidom, Node
import openshot
-from PyQt5.QtWidgets import QFileDialog
+from qt_api import QFileDialog
from classes import info
from classes.app import get_app
diff --git a/src/classes/info.py b/src/classes/info.py
index 0cb26d304..02c9ab49d 100644
--- a/src/classes/info.py
+++ b/src/classes/info.py
@@ -80,7 +80,7 @@
}
try:
- from PyQt5.QtCore import QSize
+ from qt_api import QSize
# UI Thumbnail settings
LIST_ICON_SIZE = QSize(100, 65)
@@ -88,9 +88,13 @@
TREE_ICON_SIZE = QSize(75, 49)
EMOJI_ICON_SIZE = QSize(75, 75)
EMOJI_GRID_SIZE = EMOJI_ICON_SIZE + QSize(5, 25)
+ # Runtime emoji selections
+ EMOJI_FILES = {}
+ EMOJI_PATH = ""
+ EMOJI_ICON = ""
except ImportError:
- # Fail gracefully if we're running without PyQt5 (e.g. CI tasks)
- print("Failed to import `PyQt5.QtCore.QSize` (ignoring exception)")
+ # Fail gracefully if we're running without Qt (e.g. CI tasks)
+ print("Failed to import `qt_api.QSize` (ignoring exception)")
# Maintainer details, for packaging
JT = {"name": "Jonathan Thomas",
@@ -143,7 +147,7 @@
# Compile language list from :/locale resource
try:
- from PyQt5.QtCore import QDir
+ from qt_api import QDir
langdir = QDir(language_path)
trpaths = langdir.entryList(
['OpenShot_*.qm'],
@@ -154,8 +158,8 @@
lang=trpath[trpath.find('_')+1:-3]
SUPPORTED_LANGUAGES.append(lang)
except ImportError:
- # Fail gracefully if we're running without PyQt5 (e.g. CI tasks)
- print("Failed to import `PyQt5.QtCore.QDir` (ignoring exception)")
+ # Fail gracefully if we're running without Qt (e.g. CI tasks)
+ print("Failed to import `qt_api.QDir` (ignoring exception)")
SETUP = {
"name": NAME,
diff --git a/src/classes/language.py b/src/classes/language.py
index b49294ed2..5c035e6b3 100644
--- a/src/classes/language.py
+++ b/src/classes/language.py
@@ -29,7 +29,7 @@
import os
import locale
-from PyQt5.QtCore import QLocale, QLibraryInfo, QTranslator, QCoreApplication
+from qt_api import QLocale, QLibraryInfo, QTranslator, QCoreApplication
from classes.logger import log
from classes import info
diff --git a/src/classes/metrics.py b/src/classes/metrics.py
index 97ce04ef1..bb4750ba6 100644
--- a/src/classes/metrics.py
+++ b/src/classes/metrics.py
@@ -39,7 +39,7 @@
import openshot
-from PyQt5.QtCore import QTimer, QT_VERSION_STR, PYQT_VERSION_STR
+from qt_api import QTimer, QT_VERSION_STR, PYQT_VERSION_STR
from functools import partial
try:
diff --git a/src/classes/openshot_rc.py b/src/classes/openshot_rc.py
index 03087048b..ec1088642 100644
--- a/src/classes/openshot_rc.py
+++ b/src/classes/openshot_rc.py
@@ -6,7 +6,7 @@
#
# WARNING! All changes made in this file will be lost!
-from PyQt5 import QtCore
+from qt_api import QtCore
qt_resource_data = b"\
\x00\x02\xc8\x54\
diff --git a/src/classes/qt_types.py b/src/classes/qt_types.py
index 2004e32f0..9aad110bd 100644
--- a/src/classes/qt_types.py
+++ b/src/classes/qt_types.py
@@ -27,7 +27,7 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QByteArray
+from qt_api import QByteArray
# Utility functions for handling qt types
diff --git a/src/classes/tabstops.py b/src/classes/tabstops.py
index 5c5330d60..b8228c66f 100644
--- a/src/classes/tabstops.py
+++ b/src/classes/tabstops.py
@@ -3,8 +3,8 @@
@brief Auto-assign tab order based on on-screen widget geometry.
"""
-from PyQt5.QtCore import Qt, QPoint, QTimer
-from PyQt5.QtWidgets import (
+from qt_api import Qt, QPoint, QTimer
+from qt_api import (
QWidget,
QLayout,
QToolBar,
@@ -103,6 +103,18 @@ def _is_focusable(widget, root, include_hidden, include_disabled):
return True
+def safe_set_tab_order(first, second):
+ """Set tab order only when both widgets share the same window."""
+ if first is None or second is None:
+ return
+ try:
+ if first.window() is not second.window():
+ return
+ except RuntimeError:
+ return
+ QWidget.setTabOrder(first, second)
+
+
def _position_key(widget, root, fallback_index, row_tolerance):
try:
pos = widget.mapTo(root, QPoint(0, 0))
@@ -451,7 +463,7 @@ def apply_auto_tab_order(root, include_hidden=False, include_disabled=False, row
pass # Widget may not support dynamic attributes or may be deleted
for first, second in zip(ordered_widgets, ordered_widgets[1:]):
- QWidget.setTabOrder(first, second)
+ safe_set_tab_order(first, second)
def apply_auto_tab_order_later(root, include_hidden=False, include_disabled=False, row_tolerance=8):
@@ -502,7 +514,7 @@ def apply_explicit_tab_order(
ordered.append(widget)
seen.add(widget)
for first, second in zip(ordered, ordered[1:]):
- QWidget.setTabOrder(first, second)
+ safe_set_tab_order(first, second)
def apply_explicit_tab_order_later(
diff --git a/src/classes/title_bar.py b/src/classes/title_bar.py
index 8aea50f96..8c3928c1a 100644
--- a/src/classes/title_bar.py
+++ b/src/classes/title_bar.py
@@ -25,7 +25,7 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtWidgets import QWidget, QHBoxLayout, QPushButton, QLabel
+from qt_api import QWidget, QHBoxLayout, QPushButton, QLabel
from classes.app import get_app
@@ -73,9 +73,14 @@ def __init__(self, dock_widget, title_text=""):
layout.addWidget(self.undock_button)
layout.addWidget(self.close_button)
- # Set margins and reduce height for the title bar
+ # Set margins and height for the title bar
layout.setContentsMargins(0, 0, 0, 0)
- self.setFixedHeight(20) # Reduced height
+ if title_text:
+ # Taller for title with bottom margin
+ self.setFixedHeight(40)
+ else:
+ # Shorter for just drag handle + buttons (tabbed docks)
+ self.setFixedHeight(20)
self._update_accessible_labels()
def update_title(self, text):
diff --git a/src/classes/ui_util.py b/src/classes/ui_util.py
index 394f298bd..6d3eb4dd2 100644
--- a/src/classes/ui_util.py
+++ b/src/classes/ui_util.py
@@ -36,11 +36,11 @@
except ImportError:
from xml.etree import ElementTree
-from PyQt5.QtCore import Qt, QDir, QLocale
-from PyQt5.QtGui import QIcon, QPalette, QColor
-from PyQt5.QtWidgets import (
+from qt_api import Qt, QDir, QLocale
+from qt_api import QIcon, QPalette, QColor
+from qt_api import (
QApplication, QWidget, QTabWidget, QAction)
-from PyQt5 import uic
+from qt_api import uic, load_ui as qt_load_ui
from classes.app import get_app
from classes.logger import log
@@ -77,7 +77,10 @@ def load_ui(window, path):
for attempt in range(1, 6):
try:
# Load ui from configured path
- uic.loadUi(path, window)
+ if uic is not None and hasattr(uic, "loadUi"):
+ uic.loadUi(path, window)
+ else:
+ qt_load_ui(path, window)
# Successfully loaded UI file, so clear any previously encountered errors
error = None
@@ -254,11 +257,16 @@ def connect_auto_events(window, elem, name):
func_name = name + "_trigger"
if hasattr(window, func_name) and callable(getattr(window, func_name)):
# Disconnect existing connections safely
- try:
- while True:
- elem.triggered.disconnect()
- except TypeError:
- pass # No more connections to disconnect
+ while True:
+ try:
+ disconnected = elem.triggered.disconnect()
+ except TypeError:
+ break # No more connections to disconnect (PyQt)
+ except Exception:
+ break
+ else:
+ if disconnected is False:
+ break # No more connections to disconnect (PySide)
# Connect the signal to the slot
elem.triggered.connect(getattr(window, func_name))
@@ -267,11 +275,16 @@ def connect_auto_events(window, elem, name):
func_name = name + "_click"
if hasattr(window, func_name) and callable(getattr(window, func_name)):
# Disconnect existing connections safely
- try:
- while True:
- elem.clicked.disconnect()
- except TypeError:
- pass # No more connections to disconnect
+ while True:
+ try:
+ disconnected = elem.clicked.disconnect()
+ except TypeError:
+ break # No more connections to disconnect (PyQt)
+ except Exception:
+ break
+ else:
+ if disconnected is False:
+ break # No more connections to disconnect (PySide)
# Connect the signal to the slot
elem.clicked.connect(getattr(window, func_name))
@@ -314,4 +327,3 @@ def transfer_children(from_widget, to_widget):
log.info(
"Transferring children from '%s' to '%s'",
from_widget.objectName(), to_widget.objectName())
-
diff --git a/src/classes/waveform.py b/src/classes/waveform.py
index 57a2fad8b..51baa5fce 100644
--- a/src/classes/waveform.py
+++ b/src/classes/waveform.py
@@ -26,15 +26,13 @@
"""
import threading
+import uuid
from functools import partial
from classes.app import get_app
from classes.logger import log
from classes.query import File, Clip
from classes.clip_utils import project_fps_fraction, video_length_to_project_frames
-from PyQt5.QtGui import QCursor
-from PyQt5.QtCore import Qt
-import openshot
-import uuid
+from qt_api import QCursor
# resolution of audio waveform
SAMPLES_PER_SECOND = 20
diff --git a/src/language/openshot_lang.py b/src/language/openshot_lang.py
index ce9fe92e2..921cfff85 100644
--- a/src/language/openshot_lang.py
+++ b/src/language/openshot_lang.py
@@ -6,7 +6,7 @@
#
# WARNING! All changes made in this file will be lost!
-from PyQt5 import QtCore
+from qt_api import QtCore
qt_resource_data = b"\
\x00\x02\x1d\xa9\
diff --git a/src/language/show_translations.py b/src/language/show_translations.py
index c4c50ced5..ec5debc5e 100755
--- a/src/language/show_translations.py
+++ b/src/language/show_translations.py
@@ -31,7 +31,7 @@
import re
import fnmatch
import sys
-from PyQt5.QtCore import QLocale, QLibraryInfo, QTranslator, QCoreApplication
+from qt_api import QLocale, QLibraryInfo, QTranslator, QCoreApplication
# Get the absolute path of this project
diff --git a/src/language/test_translations.py b/src/language/test_translations.py
index eee152260..9668d1446 100755
--- a/src/language/test_translations.py
+++ b/src/language/test_translations.py
@@ -30,7 +30,7 @@
import re
import fnmatch
import sys
-from PyQt5.QtCore import QTranslator, QCoreApplication # type: ignore
+from qt_api import QTranslator, QCoreApplication # type: ignore
from typing import Any, Dict, List, Optional, Tuple
diff --git a/src/launch.py b/src/launch.py
index 9b8424267..5ab1c9955 100755
--- a/src/launch.py
+++ b/src/launch.py
@@ -72,8 +72,10 @@
if scale != 1.0:
os.environ["QT_SCALE_FACTOR"] = str(scale)
-from PyQt5.QtCore import Qt
-from PyQt5.QtWidgets import QApplication
+from qt_api import QtCore, QtWidgets, QtWebEngineWidgets, QT_API
+
+Qt = QtCore.Qt
+QApplication = QtWidgets.QApplication
try:
# This apparently has to be done before loading QtQuick
@@ -86,8 +88,8 @@
try:
# QtWebEngineWidgets must be loaded prior to creating a QApplication
# But on systems with only WebKit, this will fail (and we ignore the failure)
- from PyQt5 import QtWebEngineWidgets
- WebEngineView = QtWebEngineWidgets.QWebEngineView
+ if QtWebEngineWidgets:
+ WebEngineView = QtWebEngineWidgets.QWebEngineView
except ImportError:
pass
@@ -235,7 +237,10 @@ def main():
# Launch GUI and start event loop
if app.gui():
- sys.exit(app.exec_())
+ exec_fn = getattr(app, "exec", None) or getattr(app, "exec_", None)
+ if exec_fn is None:
+ raise AttributeError("OpenShotApp has no exec_/exec method")
+ sys.exit(exec_fn())
if __name__ == "__main__":
diff --git a/src/qt_api.py b/src/qt_api.py
new file mode 100644
index 000000000..ec09befd1
--- /dev/null
+++ b/src/qt_api.py
@@ -0,0 +1,1796 @@
+"""
+Centralized Qt binding loader for OpenShot.
+
+Selects an available binding (PyQt6/PySide6/PyQt5) using the
+`OPENSHOT_QT_API` env var (`auto` default, otherwise one of
+`pyqt6|pyside6|pyqt5`). Logs the selection attempts, failures,
+and final choice to help diagnose environment issues.
+"""
+
+import logging
+import os
+import ctypes
+import sys
+from typing import List, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+
+# Public exports filled in after binding selection
+QtCore = QtGui = QtWidgets = QtSvg = QtWebEngineCore = QtWebEngineWidgets = QtWebChannel = QtWebKitWidgets = None
+Signal = Slot = Property = None
+QRegularExpression = None
+QState = QStateMachine = None
+uic = None
+QT_API: Optional[str] = None
+QT_VERSION_STR: Optional[str] = None
+PYQT_VERSION_STR: Optional[str] = None
+BINDING_VERSION_STR: Optional[str] = None
+_MODULES = []
+_FAILED_IMPORT: Optional[Exception] = None
+_SELECTING = False
+
+_shiboken_ext_load_error = None
+_openshot_shiboken_ext = None
+if sys.platform == "android":
+ try:
+ import openshot_shiboken_ext as _openshot_shiboken_ext # type: ignore
+ except Exception as exc:
+ _shiboken_ext_load_error = exc
+ if _openshot_shiboken_ext is not None:
+ logger.warning("qt_api: openshot_shiboken_ext loaded successfully")
+ print("qt_api: openshot_shiboken_ext loaded successfully", file=sys.stderr, flush=True)
+ else:
+ logger.warning("qt_api: openshot_shiboken_ext failed to load: %r", _shiboken_ext_load_error)
+ print(
+ f"qt_api: openshot_shiboken_ext failed to load: {_shiboken_ext_load_error!r}",
+ file=sys.stderr,
+ flush=True,
+ )
+
+
+def _load_sip_like():
+ """Return ('sip'|'shiboken', module) for the active binding."""
+ if QT_API is None:
+ _select_binding()
+ if QT_API == "pyqt6":
+ try:
+ from PyQt6 import sip as sip_mod # type: ignore
+ except Exception:
+ sip_mod = None
+ return ("sip", sip_mod)
+ if QT_API == "pyqt5":
+ try:
+ from PyQt5 import sip as sip_mod # type: ignore
+ except Exception:
+ sip_mod = None
+ return ("sip", sip_mod)
+ if QT_API == "pyside6":
+ try:
+ import shiboken6 as shiboken_mod # type: ignore
+ except Exception:
+ shiboken_mod = None
+ return ("shiboken", shiboken_mod)
+ return ("sip", None)
+
+
+def unwrapinstance(obj):
+ """Return the underlying C++ pointer for a Qt object."""
+ backend, mod = _load_sip_like()
+ if mod is None:
+ raise RuntimeError("No SIP/shiboken module available for unwrapinstance()")
+ if backend == "sip":
+ return mod.unwrapinstance(obj)
+ return mod.getCppPointer(obj)[0]
+
+
+def wrapinstance(ptr, base_type):
+ """Wrap a C++ pointer into a Qt object."""
+ backend, mod = _load_sip_like()
+ if mod is None:
+ raise RuntimeError("No SIP/shiboken module available for wrapinstance()")
+ if backend == "sip":
+ return mod.wrapinstance(ptr, base_type)
+ if _openshot_shiboken_ext is not None:
+ logger.warning(
+ "qt_api: Using openshot_shiboken_ext.wrap_instance_u64 for ptr=%r base=%r",
+ ptr,
+ base_type,
+ )
+ return _openshot_shiboken_ext.wrap_instance_u64(ptr, base_type)
+ if _shiboken_ext_load_error is not None:
+ logger.warning("qt_api: openshot_shiboken_ext unavailable: %s", _shiboken_ext_load_error)
+ ptr_in = int(ptr)
+ # Shiboken expects a signed pointer-sized integer. If we got an unsigned
+ # 64-bit value with the high bit set, convert it to signed.
+ if ptr_in >= (1 << 63):
+ ptr_in -= (1 << 64)
+ ptr_norm = ctypes.c_void_p(ptr_in).value
+ return mod.wrapInstance(ptr_norm, base_type)
+
+
+def isdeleted(obj):
+ """Return True if the Qt object has been deleted."""
+ backend, mod = _load_sip_like()
+ if mod is None:
+ return False
+ if backend == "sip":
+ return mod.isdeleted(obj)
+ return not mod.isValid(obj)
+
+
+def modifiers_has(modifiers, flag):
+ """Return True if a modifier flag is set on a modifiers bitmask."""
+ try:
+ return bool(modifiers & flag)
+ except Exception:
+ try:
+ return bool(int(modifiers) & int(flag))
+ except Exception:
+ return False
+
+
+def clear_override_cursor():
+ """Clear any active QApplication override cursors."""
+ try:
+ while QtWidgets.QApplication.overrideCursor():
+ QtWidgets.QApplication.restoreOverrideCursor()
+ except Exception:
+ pass
+
+
+def make_filter_regex(pattern: str, case_insensitive: bool = True):
+ """Create a cross-binding regex for QSortFilterProxyModel filters."""
+ if QT_API in ("pyqt6", "pyside6") and QRegularExpression is not None:
+ regex = QRegularExpression(pattern)
+ if case_insensitive:
+ regex.setPatternOptions(QRegularExpression.CaseInsensitiveOption)
+ return regex
+ # PyQt5 path (QRegExp)
+ QRegExp = getattr(QtCore, "QRegExp", None)
+ if QRegExp is not None:
+ cs = QtCore.Qt.CaseInsensitive if case_insensitive else QtCore.Qt.CaseSensitive
+ return QRegExp(pattern, cs)
+ # Fallback to QRegularExpression if available
+ if QRegularExpression is not None:
+ regex = QRegularExpression(pattern)
+ if case_insensitive:
+ regex.setPatternOptions(QRegularExpression.CaseInsensitiveOption)
+ return regex
+ return pattern
+
+
+def set_proxy_filter(proxy, regex):
+ """Set a filter regex on a QSortFilterProxyModel, across bindings."""
+ if hasattr(proxy, "setFilterRegularExpression") and QRegularExpression is not None and isinstance(regex, QRegularExpression):
+ return proxy.setFilterRegularExpression(regex)
+ return proxy.setFilterRegExp(regex)
+
+
+def get_proxy_filter_regex(proxy):
+ """Get the current filter regex from a QSortFilterProxyModel."""
+ if QT_API == "pyqt5":
+ return proxy.filterRegExp()
+ if hasattr(proxy, "filterRegularExpression") and QRegularExpression is not None:
+ try:
+ return proxy.filterRegularExpression()
+ except Exception:
+ pass
+ # Fallback to legacy API if present or non-empty
+ return proxy.filterRegExp()
+
+
+def regex_is_empty(regex):
+ """Return True if the regex has no pattern."""
+ if regex is None:
+ return True
+ if QRegularExpression is not None and isinstance(regex, QRegularExpression):
+ return not regex.pattern()
+ if hasattr(regex, "isEmpty"):
+ try:
+ return regex.isEmpty()
+ except Exception:
+ return True
+ return not bool(regex)
+
+
+def regex_matches(regex, text):
+ """Return True if regex matches text, across bindings."""
+ if text is None:
+ text = ""
+ if QRegularExpression is not None and isinstance(regex, QRegularExpression):
+ return regex.match(text).hasMatch()
+ if hasattr(regex, "indexIn"):
+ return regex.indexIn(text) >= 0
+ return False
+
+
+def _patch_enums_for_qt6():
+ """Backfill Qt5-style enum attributes on Qt6 scoped enums."""
+ if QT_API not in ("pyqt6", "pyside6"):
+ return
+ QDir = getattr(QtCore, "QDir", None)
+ if QDir:
+ # Filters
+ filt = getattr(QDir, "Filter", None) or getattr(QDir, "Filters", None)
+ if filt:
+ for name, val in vars(filt).items():
+ if name.startswith("_"):
+ continue
+ if not hasattr(QDir, name):
+ try:
+ setattr(QDir, name, val)
+ except Exception:
+ pass
+
+ QLibraryInfo = getattr(QtCore, "QLibraryInfo", None)
+ if QLibraryInfo:
+ # Backfill TranslationsPath constant and location() alias
+ lib_path_enum = getattr(QLibraryInfo, "LibraryPath", None)
+ if lib_path_enum and not hasattr(QLibraryInfo, "TranslationsPath"):
+ try:
+ setattr(QLibraryInfo, "TranslationsPath", lib_path_enum.TranslationsPath)
+ except Exception:
+ pass
+ if hasattr(QLibraryInfo, "path") and not hasattr(QLibraryInfo, "location"):
+ try:
+ setattr(QLibraryInfo, "location", staticmethod(QLibraryInfo.path))
+ except Exception:
+ pass
+ # Sort flags
+ sort = getattr(QDir, "SortFlag", None) or getattr(QDir, "SortFlags", None)
+ if sort:
+ for name, val in vars(sort).items():
+ if name.startswith("_"):
+ continue
+ if not hasattr(QDir, name):
+ try:
+ setattr(QDir, name, val)
+ except Exception:
+ pass
+
+ QMetaMethod = getattr(QtCore, "QMetaMethod", None)
+ if QMetaMethod and not hasattr(QMetaMethod, "Signal"):
+ method_type = getattr(QMetaMethod, "MethodType", None)
+ if method_type and hasattr(method_type, "Signal"):
+ try:
+ setattr(QMetaMethod, "Signal", method_type.Signal)
+ except Exception:
+ pass
+
+ QEvent = getattr(QtCore, "QEvent", None)
+ if QEvent:
+ event_type = getattr(QEvent, "Type", None)
+ if event_type:
+ for name in ("ShortcutOverride", "Resize", "Paint"):
+ if hasattr(event_type, name) and not hasattr(QEvent, name):
+ try:
+ setattr(QEvent, name, getattr(event_type, name))
+ except Exception:
+ pass
+ QEventLoop = getattr(QtCore, "QEventLoop", None)
+ if QEventLoop and not hasattr(QEventLoop, "ExcludeUserInputEvents"):
+ process_flag = getattr(QEventLoop, "ProcessEventsFlag", None)
+ if process_flag and hasattr(process_flag, "ExcludeUserInputEvents"):
+ try:
+ setattr(QEventLoop, "ExcludeUserInputEvents", process_flag.ExcludeUserInputEvents)
+ except Exception:
+ pass
+ if process_flag and hasattr(process_flag, "ExcludeSocketNotifiers"):
+ if not hasattr(QEventLoop, "ExcludeSocketNotifiers"):
+ try:
+ setattr(QEventLoop, "ExcludeSocketNotifiers", process_flag.ExcludeSocketNotifiers)
+ except Exception:
+ pass
+
+ if QtCore and not hasattr(QtCore, "Qt"):
+ return
+ if not hasattr(QtCore.Qt, "WA_OpaquePaintEvent"):
+ widget_attr = getattr(QtCore.Qt, "WidgetAttribute", None)
+ if widget_attr and hasattr(widget_attr, "WA_OpaquePaintEvent"):
+ try:
+ setattr(QtCore.Qt, "WA_OpaquePaintEvent", widget_attr.WA_OpaquePaintEvent)
+ except Exception:
+ pass
+ if not hasattr(QtCore.Qt, "WA_DeleteOnClose"):
+ widget_attr = getattr(QtCore.Qt, "WidgetAttribute", None)
+ if widget_attr and hasattr(widget_attr, "WA_DeleteOnClose"):
+ try:
+ setattr(QtCore.Qt, "WA_DeleteOnClose", widget_attr.WA_DeleteOnClose)
+ except Exception:
+ pass
+ if not hasattr(QtCore.Qt, "WA_NoSystemBackground"):
+ widget_attr = getattr(QtCore.Qt, "WidgetAttribute", None)
+ if widget_attr and hasattr(widget_attr, "WA_NoSystemBackground"):
+ try:
+ setattr(QtCore.Qt, "WA_NoSystemBackground", widget_attr.WA_NoSystemBackground)
+ except Exception:
+ pass
+ if not hasattr(QtCore.Qt, "WA_TranslucentBackground"):
+ widget_attr = getattr(QtCore.Qt, "WidgetAttribute", None)
+ if widget_attr and hasattr(widget_attr, "WA_TranslucentBackground"):
+ try:
+ setattr(QtCore.Qt, "WA_TranslucentBackground", widget_attr.WA_TranslucentBackground)
+ except Exception:
+ pass
+ if not hasattr(QtCore.Qt, "WA_TransparentForMouseEvents"):
+ widget_attr = getattr(QtCore.Qt, "WidgetAttribute", None)
+ if widget_attr and hasattr(widget_attr, "WA_TransparentForMouseEvents"):
+ try:
+ setattr(QtCore.Qt, "WA_TransparentForMouseEvents", widget_attr.WA_TransparentForMouseEvents)
+ except Exception:
+ pass
+
+ corner_enum = getattr(QtCore.Qt, "Corner", None)
+ if corner_enum:
+ for name in ("TopLeftCorner", "TopRightCorner", "BottomLeftCorner", "BottomRightCorner"):
+ if hasattr(corner_enum, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(corner_enum, name))
+ except Exception:
+ pass
+
+ dock_enum = getattr(QtCore.Qt, "DockWidgetArea", None)
+ if dock_enum:
+ for name in (
+ "LeftDockWidgetArea",
+ "RightDockWidgetArea",
+ "TopDockWidgetArea",
+ "BottomDockWidgetArea",
+ "AllDockWidgetAreas",
+ "NoDockWidgetArea",
+ ):
+ if hasattr(dock_enum, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(dock_enum, name))
+ except Exception:
+ pass
+
+ case_enum = getattr(QtCore.Qt, "CaseSensitivity", None)
+ if case_enum:
+ for name in ("CaseSensitive", "CaseInsensitive"):
+ if hasattr(case_enum, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(case_enum, name))
+ except Exception:
+ pass
+
+ elide_enum = getattr(QtCore.Qt, "TextElideMode", None)
+ if elide_enum:
+ for name in ("ElideLeft", "ElideRight", "ElideMiddle", "ElideNone"):
+ if hasattr(elide_enum, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(elide_enum, name))
+ except Exception:
+ pass
+
+ sort_enum = getattr(QtCore.Qt, "SortOrder", None)
+ if sort_enum:
+ for name in ("AscendingOrder", "DescendingOrder"):
+ if hasattr(sort_enum, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(sort_enum, name))
+ except Exception:
+ pass
+
+ item_flag = getattr(QtCore.Qt, "ItemFlag", None)
+ if item_flag:
+ for name in (
+ "NoItemFlags",
+ "ItemIsSelectable",
+ "ItemIsEditable",
+ "ItemIsDragEnabled",
+ "ItemIsDropEnabled",
+ "ItemIsUserCheckable",
+ "ItemIsEnabled",
+ "ItemIsAutoTristate",
+ "ItemIsTristate",
+ "ItemNeverHasChildren",
+ ):
+ if hasattr(item_flag, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(item_flag, name))
+ except Exception:
+ pass
+
+ check_state = getattr(QtCore.Qt, "CheckState", None)
+ if check_state:
+ for name in ("Unchecked", "PartiallyChecked", "Checked"):
+ if hasattr(check_state, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(check_state, name))
+ except Exception:
+ pass
+
+ size_mode = getattr(QtCore.Qt, "SizeMode", None)
+ if size_mode:
+ for name in ("AbsoluteSize", "RelativeSize"):
+ if hasattr(size_mode, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(size_mode, name))
+ except Exception:
+ pass
+
+ keyboard_modifier = getattr(QtCore.Qt, "KeyboardModifier", None)
+ if keyboard_modifier:
+ for name in ("NoModifier", "ShiftModifier", "ControlModifier", "AltModifier", "MetaModifier", "KeypadModifier", "GroupSwitchModifier"):
+ if hasattr(keyboard_modifier, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(keyboard_modifier, name))
+ except Exception:
+ pass
+
+ mouse_button = getattr(QtCore.Qt, "MouseButton", None)
+ if mouse_button:
+ for name in ("NoButton", "LeftButton", "RightButton", "MiddleButton", "BackButton", "ForwardButton", "TaskButton"):
+ if hasattr(mouse_button, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(mouse_button, name))
+ except Exception:
+ pass
+
+ clip_operation = getattr(QtCore.Qt, "ClipOperation", None)
+ if clip_operation:
+ for name in ("NoClip", "ReplaceClip", "IntersectClip"):
+ if hasattr(clip_operation, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(clip_operation, name))
+ except Exception:
+ pass
+
+ item_data_role = getattr(QtCore.Qt, "ItemDataRole", None)
+ if item_data_role:
+ for name in (
+ "DisplayRole",
+ "DecorationRole",
+ "EditRole",
+ "ToolTipRole",
+ "StatusTipRole",
+ "WhatsThisRole",
+ "FontRole",
+ "TextAlignmentRole",
+ "BackgroundRole",
+ "ForegroundRole",
+ "CheckStateRole",
+ "InitialSortOrderRole",
+ "AccessibleTextRole",
+ "AccessibleDescriptionRole",
+ "SizeHintRole",
+ "UserRole",
+ ):
+ if hasattr(item_data_role, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(item_data_role, name))
+ except Exception:
+ pass
+
+ alignment_flag = getattr(QtCore.Qt, "AlignmentFlag", None)
+ if alignment_flag:
+ for name in (
+ "AlignLeft",
+ "AlignRight",
+ "AlignHCenter",
+ "AlignJustify",
+ "AlignTop",
+ "AlignBottom",
+ "AlignVCenter",
+ "AlignBaseline",
+ "AlignCenter",
+ "AlignLeading",
+ "AlignTrailing",
+ "AlignAbsolute",
+ ):
+ if hasattr(alignment_flag, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(alignment_flag, name))
+ except Exception:
+ pass
+
+ pen_style = getattr(QtCore.Qt, "PenStyle", None)
+ if pen_style:
+ for name in (
+ "NoPen",
+ "SolidLine",
+ "DashLine",
+ "DotLine",
+ "DashDotLine",
+ "DashDotDotLine",
+ "CustomDashLine",
+ ):
+ if hasattr(pen_style, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(pen_style, name))
+ except Exception:
+ pass
+
+ brush_style = getattr(QtCore.Qt, "BrushStyle", None)
+ if brush_style:
+ for name in (
+ "NoBrush",
+ "SolidPattern",
+ "Dense1Pattern",
+ "Dense2Pattern",
+ "Dense3Pattern",
+ "Dense4Pattern",
+ "Dense5Pattern",
+ "Dense6Pattern",
+ "Dense7Pattern",
+ "HorPattern",
+ "VerPattern",
+ "CrossPattern",
+ "BDiagPattern",
+ "FDiagPattern",
+ "DiagCrossPattern",
+ ):
+ if hasattr(brush_style, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(brush_style, name))
+ except Exception:
+ pass
+
+ text_format = getattr(QtCore.Qt, "TextFormat", None)
+ if text_format:
+ for name in ("PlainText", "RichText", "AutoText", "MarkdownText"):
+ if hasattr(text_format, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(text_format, name))
+ except Exception:
+ pass
+
+ cursor_shape = getattr(QtCore.Qt, "CursorShape", None)
+ if cursor_shape:
+ for name in (
+ "ArrowCursor",
+ "UpArrowCursor",
+ "CrossCursor",
+ "WaitCursor",
+ "IBeamCursor",
+ "SizeVerCursor",
+ "SizeHorCursor",
+ "SizeBDiagCursor",
+ "SizeFDiagCursor",
+ "SizeAllCursor",
+ "BlankCursor",
+ "SplitVCursor",
+ "SplitHCursor",
+ "PointingHandCursor",
+ "ForbiddenCursor",
+ "WhatsThisCursor",
+ "BusyCursor",
+ "OpenHandCursor",
+ "ClosedHandCursor",
+ "DragCopyCursor",
+ "DragMoveCursor",
+ "DragLinkCursor",
+ ):
+ if hasattr(cursor_shape, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(cursor_shape, name))
+ except Exception:
+ pass
+
+ connection_type = getattr(QtCore.Qt, "ConnectionType", None)
+ if connection_type:
+ for name in ("AutoConnection", "DirectConnection", "QueuedConnection", "BlockingQueuedConnection", "UniqueConnection"):
+ if hasattr(connection_type, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(connection_type, name))
+ except Exception:
+ pass
+
+ window_type = getattr(QtCore.Qt, "WindowType", None)
+ if window_type:
+ for name in (
+ "Widget",
+ "Window",
+ "Dialog",
+ "Sheet",
+ "Drawer",
+ "Popup",
+ "Tool",
+ "ToolTip",
+ "SplashScreen",
+ "Desktop",
+ "SubWindow",
+ "ForeignWindow",
+ "CoverWindow",
+ "WindowTitleHint",
+ "WindowSystemMenuHint",
+ "WindowMinimizeButtonHint",
+ "WindowMaximizeButtonHint",
+ "WindowCloseButtonHint",
+ "WindowContextHelpButtonHint",
+ "MacWindowToolBarButtonHint",
+ "WindowFullscreenButtonHint",
+ "BypassWindowManagerHint",
+ "CustomizeWindowHint",
+ "WindowStaysOnTopHint",
+ "WindowStaysOnBottomHint",
+ "WindowTransparentForInput",
+ "WindowOverridesSystemGestures",
+ "WindowDoesNotAcceptFocus",
+ "WindowType_Mask",
+ "FramelessWindowHint",
+ ):
+ if hasattr(window_type, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(window_type, name))
+ except Exception:
+ pass
+
+ tool_button_style = getattr(QtCore.Qt, "ToolButtonStyle", None)
+ if tool_button_style:
+ for name in (
+ "ToolButtonIconOnly",
+ "ToolButtonTextOnly",
+ "ToolButtonTextBesideIcon",
+ "ToolButtonTextUnderIcon",
+ "ToolButtonFollowStyle",
+ ):
+ if hasattr(tool_button_style, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(tool_button_style, name))
+ except Exception:
+ pass
+
+ shortcut_context = getattr(QtCore.Qt, "ShortcutContext", None)
+ if shortcut_context:
+ for name in ("WidgetShortcut", "WindowShortcut", "ApplicationShortcut", "WidgetWithChildrenShortcut"):
+ if hasattr(shortcut_context, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(shortcut_context, name))
+ except Exception:
+ pass
+
+ focus_policy = getattr(QtCore.Qt, "FocusPolicy", None)
+ if focus_policy:
+ for name in ("NoFocus", "TabFocus", "ClickFocus", "StrongFocus", "WheelFocus"):
+ if hasattr(focus_policy, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(focus_policy, name))
+ except Exception:
+ pass
+
+ focus_reason = getattr(QtCore.Qt, "FocusReason", None)
+ if focus_reason:
+ for name in ("MouseFocusReason", "TabFocusReason", "BacktabFocusReason", "ActiveWindowFocusReason", "ShortcutFocusReason", "OtherFocusReason"):
+ if hasattr(focus_reason, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(focus_reason, name))
+ except Exception:
+ pass
+
+ orientation = getattr(QtCore.Qt, "Orientation", None)
+ if orientation:
+ for name in ("Horizontal", "Vertical"):
+ if hasattr(orientation, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(orientation, name))
+ except Exception:
+ pass
+
+ drop_action = getattr(QtCore.Qt, "DropAction", None)
+ if drop_action:
+ for name in ("CopyAction", "MoveAction", "LinkAction", "ActionMask", "IgnoreAction"):
+ if hasattr(drop_action, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(drop_action, name))
+ except Exception:
+ pass
+
+ text_interaction = getattr(QtCore.Qt, "TextInteractionFlag", None)
+ if text_interaction:
+ for name in ("TextBrowserInteraction", "TextSelectableByKeyboard", "TextSelectableByMouse", "LinksAccessibleByMouse", "LinksAccessibleByKeyboard"):
+ if hasattr(text_interaction, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(text_interaction, name))
+ except Exception:
+ pass
+
+ window_modality = getattr(QtCore.Qt, "WindowModality", None)
+ if window_modality:
+ for name in ("NonModal", "WindowModal", "ApplicationModal"):
+ if hasattr(window_modality, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(window_modality, name))
+ except Exception:
+ pass
+
+ window_state = getattr(QtCore.Qt, "WindowState", None)
+ if window_state:
+ for name in ("WindowNoState", "WindowMinimized", "WindowMaximized", "WindowFullScreen", "WindowActive"):
+ if hasattr(window_state, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(window_state, name))
+ except Exception:
+ pass
+
+ app_attr = getattr(QtCore.Qt, "ApplicationAttribute", None)
+ if app_attr:
+ for name in ("AA_EnableHighDpiScaling", "AA_ShareOpenGLContexts", "AA_UseHighDpiPixmaps"):
+ if hasattr(app_attr, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(app_attr, name))
+ except Exception:
+ pass
+
+ key_enum = getattr(QtCore.Qt, "Key", None)
+ if key_enum:
+ for name, val in vars(key_enum).items():
+ if name.startswith("_"):
+ continue
+ if not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, val)
+ except Exception:
+ pass
+
+ aspect_ratio_mode = getattr(QtCore.Qt, "AspectRatioMode", None)
+ if aspect_ratio_mode:
+ for name in ("IgnoreAspectRatio", "KeepAspectRatio", "KeepAspectRatioByExpanding"):
+ if hasattr(aspect_ratio_mode, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(aspect_ratio_mode, name))
+ except Exception:
+ pass
+
+ scroll_bar_policy = getattr(QtCore.Qt, "ScrollBarPolicy", None)
+ if scroll_bar_policy:
+ for name in ("ScrollBarAsNeeded", "ScrollBarAlwaysOff", "ScrollBarAlwaysOn"):
+ if hasattr(scroll_bar_policy, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(scroll_bar_policy, name))
+ except Exception:
+ pass
+
+ transformation_mode = getattr(QtCore.Qt, "TransformationMode", None)
+ if transformation_mode:
+ for name in ("FastTransformation", "SmoothTransformation"):
+ if hasattr(transformation_mode, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(transformation_mode, name))
+ except Exception:
+ pass
+
+ fill_rule = getattr(QtCore.Qt, "FillRule", None)
+ if fill_rule:
+ for name in ("OddEvenFill", "WindingFill"):
+ if hasattr(fill_rule, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(fill_rule, name))
+ except Exception:
+ pass
+
+ QPainter = getattr(QtGui, "QPainter", None)
+ if QPainter and not hasattr(QPainter, "Antialiasing"):
+ render_hint = getattr(QPainter, "RenderHint", None)
+ if render_hint:
+ for name in (
+ "Antialiasing",
+ "TextAntialiasing",
+ "SmoothPixmapTransform",
+ "HighQualityAntialiasing",
+ "NonCosmeticDefaultPen",
+ "LosslessImageRendering",
+ ):
+ if hasattr(render_hint, name) and not hasattr(QPainter, name):
+ try:
+ setattr(QPainter, name, getattr(render_hint, name))
+ except Exception:
+ pass
+ if QPainter and not hasattr(QPainter, "CompositionMode_SourceOver"):
+ composition_mode = getattr(QPainter, "CompositionMode", None)
+ if composition_mode:
+ for name in (
+ "CompositionMode_SourceOver",
+ "CompositionMode_DestinationOver",
+ "CompositionMode_Clear",
+ "CompositionMode_Source",
+ "CompositionMode_Destination",
+ "CompositionMode_SourceIn",
+ "CompositionMode_DestinationIn",
+ "CompositionMode_SourceOut",
+ "CompositionMode_DestinationOut",
+ "CompositionMode_SourceAtop",
+ "CompositionMode_DestinationAtop",
+ "CompositionMode_Xor",
+ "CompositionMode_Plus",
+ "CompositionMode_Multiply",
+ "CompositionMode_Screen",
+ "CompositionMode_Overlay",
+ "CompositionMode_Darken",
+ "CompositionMode_Lighten",
+ "CompositionMode_ColorDodge",
+ "CompositionMode_ColorBurn",
+ "CompositionMode_HardLight",
+ "CompositionMode_SoftLight",
+ "CompositionMode_Difference",
+ "CompositionMode_Exclusion",
+ ):
+ if hasattr(composition_mode, name) and not hasattr(QPainter, name):
+ try:
+ setattr(QPainter, name, getattr(composition_mode, name))
+ except Exception:
+ pass
+
+ global_color = getattr(QtCore.Qt, "GlobalColor", None)
+ if global_color:
+ for name in (
+ "transparent",
+ "black",
+ "white",
+ "red",
+ "darkRed",
+ "green",
+ "darkGreen",
+ "blue",
+ "darkBlue",
+ "cyan",
+ "darkCyan",
+ "magenta",
+ "darkMagenta",
+ "yellow",
+ "darkYellow",
+ "gray",
+ "darkGray",
+ "lightGray",
+ "color0",
+ "color1",
+ ):
+ if hasattr(global_color, name) and not hasattr(QtCore.Qt, name):
+ try:
+ setattr(QtCore.Qt, name, getattr(global_color, name))
+ except Exception:
+ pass
+
+ QSizePolicy = getattr(QtWidgets, "QSizePolicy", None)
+ if QSizePolicy and not hasattr(QSizePolicy, "Expanding"):
+ policy = getattr(QSizePolicy, "Policy", None)
+ if policy:
+ for name in ("Fixed", "Minimum", "Maximum", "Preferred", "Expanding", "MinimumExpanding", "Ignored"):
+ if hasattr(policy, name) and not hasattr(QSizePolicy, name):
+ try:
+ setattr(QSizePolicy, name, getattr(policy, name))
+ except Exception:
+ pass
+
+ QAbstractItemView = getattr(QtWidgets, "QAbstractItemView", None)
+ if QAbstractItemView and not hasattr(QAbstractItemView, "ExtendedSelection"):
+ selection_mode = getattr(QAbstractItemView, "SelectionMode", None)
+ if selection_mode:
+ for name in (
+ "NoSelection",
+ "SingleSelection",
+ "MultiSelection",
+ "ExtendedSelection",
+ "ContiguousSelection",
+ ):
+ if hasattr(selection_mode, name) and not hasattr(QAbstractItemView, name):
+ try:
+ setattr(QAbstractItemView, name, getattr(selection_mode, name))
+ except Exception:
+ pass
+ selection_behavior = getattr(QAbstractItemView, "SelectionBehavior", None)
+ if selection_behavior:
+ for name in ("SelectItems", "SelectRows", "SelectColumns"):
+ if hasattr(selection_behavior, name) and not hasattr(QAbstractItemView, name):
+ try:
+ setattr(QAbstractItemView, name, getattr(selection_behavior, name))
+ except Exception:
+ pass
+ scroll_hint = getattr(QAbstractItemView, "ScrollHint", None)
+ if scroll_hint:
+ for name in ("EnsureVisible", "PositionAtTop", "PositionAtBottom", "PositionAtCenter"):
+ if hasattr(scroll_hint, name) and not hasattr(QAbstractItemView, name):
+ try:
+ setattr(QAbstractItemView, name, getattr(scroll_hint, name))
+ except Exception:
+ pass
+
+ QSortFilterProxyModel = getattr(QtCore, "QSortFilterProxyModel", None)
+ if QSortFilterProxyModel:
+ if not hasattr(QSortFilterProxyModel, "setFilterRegExp") and hasattr(QSortFilterProxyModel, "setFilterRegularExpression"):
+ def _setFilterRegExp(self, exp):
+ return self.setFilterRegularExpression(exp)
+ try:
+ setattr(QSortFilterProxyModel, "setFilterRegExp", _setFilterRegExp)
+ except Exception:
+ pass
+ if not hasattr(QSortFilterProxyModel, "filterRegExp") and hasattr(QSortFilterProxyModel, "filterRegularExpression"):
+ def _filterRegExp(self):
+ return self.filterRegularExpression()
+ try:
+ setattr(QSortFilterProxyModel, "filterRegExp", _filterRegExp)
+ except Exception:
+ pass
+
+ QListView = getattr(QtWidgets, "QListView", None)
+ if QListView and not hasattr(QListView, "IconMode"):
+ view_mode = getattr(QListView, "ViewMode", None)
+ if view_mode:
+ for name in ("ListMode", "IconMode"):
+ if hasattr(view_mode, name) and not hasattr(QListView, name):
+ try:
+ setattr(QListView, name, getattr(view_mode, name))
+ except Exception:
+ pass
+ flow = getattr(QListView, "Flow", None)
+ if flow:
+ for name in ("LeftToRight", "TopToBottom"):
+ if hasattr(flow, name) and not hasattr(QListView, name):
+ try:
+ setattr(QListView, name, getattr(flow, name))
+ except Exception:
+ pass
+ layout_mode = getattr(QListView, "LayoutMode", None)
+ if layout_mode:
+ for name in ("SinglePass", "Batched"):
+ if hasattr(layout_mode, name) and not hasattr(QListView, name):
+ try:
+ setattr(QListView, name, getattr(layout_mode, name))
+ except Exception:
+ pass
+ resize_mode = getattr(QListView, "ResizeMode", None)
+ if resize_mode:
+ for name in ("Fixed", "Adjust"):
+ if hasattr(resize_mode, name) and not hasattr(QListView, name):
+ try:
+ setattr(QListView, name, getattr(resize_mode, name))
+ except Exception:
+ pass
+
+ QHeaderView = getattr(QtWidgets, "QHeaderView", None)
+ if QHeaderView and not hasattr(QHeaderView, "Stretch"):
+ resize_mode = getattr(QHeaderView, "ResizeMode", None)
+ if resize_mode:
+ for name in ("Interactive", "Stretch", "Fixed", "ResizeToContents", "Custom"):
+ if hasattr(resize_mode, name) and not hasattr(QHeaderView, name):
+ try:
+ setattr(QHeaderView, name, getattr(resize_mode, name))
+ except Exception:
+ pass
+
+ QDockWidget = getattr(QtWidgets, "QDockWidget", None)
+ if QDockWidget and not hasattr(QDockWidget, "DockWidgetClosable"):
+ dock_feature = getattr(QDockWidget, "DockWidgetFeature", None)
+ if dock_feature:
+ for name in (
+ "DockWidgetClosable",
+ "DockWidgetMovable",
+ "DockWidgetFloatable",
+ "DockWidgetVerticalTitleBar",
+ "DockWidgetFeatureMask",
+ "NoDockWidgetFeatures",
+ ):
+ if hasattr(dock_feature, name) and not hasattr(QDockWidget, name):
+ try:
+ setattr(QDockWidget, name, getattr(dock_feature, name))
+ except Exception:
+ pass
+
+ QTabWidget = getattr(QtWidgets, "QTabWidget", None)
+ if QTabWidget and not hasattr(QTabWidget, "South"):
+ tab_position = getattr(QTabWidget, "TabPosition", None)
+ if tab_position:
+ for name in ("North", "South", "West", "East"):
+ if hasattr(tab_position, name) and not hasattr(QTabWidget, name):
+ try:
+ setattr(QTabWidget, name, getattr(tab_position, name))
+ except Exception:
+ pass
+
+ QPalette = getattr(QtGui, "QPalette", None)
+ if QPalette and not hasattr(QPalette, "Window"):
+ color_role = getattr(QPalette, "ColorRole", None)
+ if color_role:
+ for name in (
+ "WindowText",
+ "Button",
+ "Light",
+ "Midlight",
+ "Dark",
+ "Mid",
+ "Text",
+ "BrightText",
+ "ButtonText",
+ "Base",
+ "Window",
+ "Shadow",
+ "Highlight",
+ "HighlightedText",
+ "Link",
+ "LinkVisited",
+ "AlternateBase",
+ "NoRole",
+ "ToolTipBase",
+ "ToolTipText",
+ "PlaceholderText",
+ ):
+ if hasattr(color_role, name) and not hasattr(QPalette, name):
+ try:
+ setattr(QPalette, name, getattr(color_role, name))
+ except Exception:
+ pass
+
+ QDialogButtonBox = getattr(QtWidgets, "QDialogButtonBox", None)
+ if QDialogButtonBox:
+ if not hasattr(QDialogButtonBox, "RejectRole"):
+ button_role = getattr(QDialogButtonBox, "ButtonRole", None)
+ if button_role:
+ for name in (
+ "InvalidRole",
+ "AcceptRole",
+ "RejectRole",
+ "DestructiveRole",
+ "ActionRole",
+ "HelpRole",
+ "YesRole",
+ "NoRole",
+ "ResetRole",
+ "ApplyRole",
+ "NRoles",
+ ):
+ if hasattr(button_role, name) and not hasattr(QDialogButtonBox, name):
+ try:
+ setattr(QDialogButtonBox, name, getattr(button_role, name))
+ except Exception:
+ pass
+ if not hasattr(QDialogButtonBox, "Ok"):
+ std_button = getattr(QDialogButtonBox, "StandardButton", None)
+ if std_button:
+ for name in (
+ "NoButton",
+ "Ok",
+ "Save",
+ "SaveAll",
+ "Open",
+ "Yes",
+ "YesToAll",
+ "No",
+ "NoToAll",
+ "Abort",
+ "Retry",
+ "Ignore",
+ "Close",
+ "Cancel",
+ "Discard",
+ "Help",
+ "Apply",
+ "Reset",
+ "RestoreDefaults",
+ ):
+ if hasattr(std_button, name) and not hasattr(QDialogButtonBox, name):
+ try:
+ setattr(QDialogButtonBox, name, getattr(std_button, name))
+ except Exception:
+ pass
+
+ QDialog = getattr(QtWidgets, "QDialog", None)
+ if QDialog and not hasattr(QDialog, "Accepted"):
+ dialog_code = getattr(QDialog, "DialogCode", None)
+ if dialog_code:
+ for name in ("Rejected", "Accepted"):
+ if hasattr(dialog_code, name) and not hasattr(QDialog, name):
+ try:
+ setattr(QDialog, name, getattr(dialog_code, name))
+ except Exception:
+ pass
+
+ QImage = getattr(QtGui, "QImage", None)
+ if QImage and not hasattr(QImage, "Format_ARGB32_Premultiplied"):
+ image_format = getattr(QImage, "Format", None)
+ if image_format:
+ for name in (
+ "Format_Invalid",
+ "Format_Mono",
+ "Format_MonoLSB",
+ "Format_Indexed8",
+ "Format_RGB32",
+ "Format_ARGB32",
+ "Format_ARGB32_Premultiplied",
+ "Format_RGB16",
+ "Format_ARGB8565_Premultiplied",
+ "Format_RGB666",
+ "Format_ARGB6666_Premultiplied",
+ "Format_RGB555",
+ "Format_ARGB8555_Premultiplied",
+ "Format_RGB888",
+ "Format_RGB444",
+ "Format_ARGB4444_Premultiplied",
+ "Format_RGBX8888",
+ "Format_RGBA8888",
+ "Format_RGBA8888_Premultiplied",
+ "Format_BGR30",
+ "Format_A2BGR30_Premultiplied",
+ "Format_RGB30",
+ "Format_A2RGB30_Premultiplied",
+ "Format_Alpha8",
+ "Format_Grayscale8",
+ ):
+ if hasattr(image_format, name) and not hasattr(QImage, name):
+ try:
+ setattr(QImage, name, getattr(image_format, name))
+ except Exception:
+ pass
+
+ QStyle = getattr(QtWidgets, "QStyle", None)
+ if QStyle and not hasattr(QStyle, "State_Selected"):
+ state_flag = getattr(QStyle, "StateFlag", None)
+ if state_flag:
+ for name in (
+ "State_None",
+ "State_Enabled",
+ "State_Raised",
+ "State_Sunken",
+ "State_Off",
+ "State_NoChange",
+ "State_On",
+ "State_DownArrow",
+ "State_Horizontal",
+ "State_HasFocus",
+ "State_Top",
+ "State_Bottom",
+ "State_FocusAtBorder",
+ "State_AutoRaise",
+ "State_MouseOver",
+ "State_UpArrow",
+ "State_Selected",
+ "State_Active",
+ "State_Window",
+ "State_Open",
+ "State_Children",
+ "State_Item",
+ "State_Sibling",
+ "State_Editing",
+ "State_KeyboardFocusChange",
+ "State_ReadOnly",
+ "State_Small",
+ "State_Mini",
+ ):
+ if hasattr(state_flag, name) and not hasattr(QStyle, name):
+ try:
+ setattr(QStyle, name, getattr(state_flag, name))
+ except Exception:
+ pass
+
+ QComboBox = getattr(QtWidgets, "QComboBox", None)
+ if QComboBox and not hasattr(QComboBox, "AdjustToMinimumContentsLengthWithIcon"):
+ size_adjust = getattr(QComboBox, "SizeAdjustPolicy", None)
+ if size_adjust:
+ for name in (
+ "AdjustToContents",
+ "AdjustToContentsOnFirstShow",
+ "AdjustToMinimumContentsLength",
+ "AdjustToMinimumContentsLengthWithIcon",
+ ):
+ if hasattr(size_adjust, name) and not hasattr(QComboBox, name):
+ try:
+ setattr(QComboBox, name, getattr(size_adjust, name))
+ except Exception:
+ pass
+
+ QColorDialog = getattr(QtWidgets, "QColorDialog", None)
+ if QColorDialog and not hasattr(QColorDialog, "DontUseNativeDialog"):
+ option = getattr(QColorDialog, "ColorDialogOption", None)
+ if option:
+ for name in (
+ "ShowAlphaChannel",
+ "NoButtons",
+ "DontUseNativeDialog",
+ ):
+ if hasattr(option, name) and not hasattr(QColorDialog, name):
+ try:
+ setattr(QColorDialog, name, getattr(option, name))
+ except Exception:
+ pass
+
+ QItemSelectionModel = getattr(QtCore, "QItemSelectionModel", None)
+ if QItemSelectionModel and not hasattr(QItemSelectionModel, "Select"):
+ selection_flag = getattr(QItemSelectionModel, "SelectionFlag", None)
+ if selection_flag:
+ for name in (
+ "NoUpdate",
+ "Clear",
+ "Select",
+ "Deselect",
+ "Toggle",
+ "Current",
+ "Rows",
+ "Columns",
+ "SelectCurrent",
+ "ToggleCurrent",
+ "ClearAndSelect",
+ ):
+ if hasattr(selection_flag, name) and not hasattr(QItemSelectionModel, name):
+ try:
+ setattr(QItemSelectionModel, name, getattr(selection_flag, name))
+ except Exception:
+ pass
+
+ QMessageBox = getattr(QtWidgets, "QMessageBox", None)
+ if QMessageBox and not hasattr(QMessageBox, "Cancel"):
+ std_button = getattr(QMessageBox, "StandardButton", None)
+ if std_button:
+ for name in (
+ "NoButton",
+ "Ok",
+ "Save",
+ "SaveAll",
+ "Open",
+ "Yes",
+ "YesToAll",
+ "No",
+ "NoToAll",
+ "Abort",
+ "Retry",
+ "Ignore",
+ "Close",
+ "Cancel",
+ "Discard",
+ "Help",
+ "Apply",
+ "Reset",
+ "RestoreDefaults",
+ ):
+ if hasattr(std_button, name) and not hasattr(QMessageBox, name):
+ try:
+ setattr(QMessageBox, name, getattr(std_button, name))
+ except Exception:
+ pass
+ icon = getattr(QMessageBox, "Icon", None)
+ if icon:
+ for name in ("NoIcon", "Information", "Warning", "Critical", "Question"):
+ if hasattr(icon, name) and not hasattr(QMessageBox, name):
+ try:
+ setattr(QMessageBox, name, getattr(icon, name))
+ except Exception:
+ pass
+ color_group = getattr(QPalette, "ColorGroup", None)
+ if color_group:
+ for name in ("Disabled", "Active", "Inactive", "Normal", "All"):
+ if hasattr(color_group, name) and not hasattr(QPalette, name):
+ try:
+ setattr(QPalette, name, getattr(color_group, name))
+ except Exception:
+ pass
+
+ if QRegularExpression and not hasattr(QRegularExpression, "CaseInsensitiveOption"):
+ pattern_option = getattr(QRegularExpression, "PatternOption", None)
+ if pattern_option:
+ for name in ("NoPatternOption", "CaseInsensitiveOption", "DotMatchesEverythingOption", "MultilineOption"):
+ if hasattr(pattern_option, name) and not hasattr(QRegularExpression, name):
+ try:
+ setattr(QRegularExpression, name, getattr(pattern_option, name))
+ except Exception:
+ pass
+ if QRegularExpression and not hasattr(QRegularExpression, "indexIn"):
+ def _index_in(self, text):
+ if text is None:
+ text = ""
+ return 0 if self.match(text).hasMatch() else -1
+ try:
+ setattr(QRegularExpression, "indexIn", _index_in)
+ except Exception:
+ pass
+
+ QWebEngineSettings = None
+ if QtWebEngineCore:
+ QWebEngineSettings = getattr(QtWebEngineCore, "QWebEngineSettings", None)
+ if QWebEngineSettings is None and QtWebEngineWidgets:
+ QWebEngineSettings = getattr(QtWebEngineWidgets, "QWebEngineSettings", None)
+ if QWebEngineSettings and not hasattr(QWebEngineSettings, "ScrollAnimatorEnabled"):
+ web_attr = getattr(QWebEngineSettings, "WebAttribute", None)
+ if web_attr and hasattr(web_attr, "ScrollAnimatorEnabled"):
+ try:
+ setattr(QWebEngineSettings, "ScrollAnimatorEnabled", web_attr.ScrollAnimatorEnabled)
+ except Exception:
+ pass
+
+ # Qt6 renamed exec_() -> exec(); backfill exec_ on common classes.
+ def _exec_wrapper(self, *args, **kwargs):
+ return self.exec(*args, **kwargs)
+
+ if QtWidgets:
+ for name in (
+ "QDialog",
+ "QMessageBox",
+ "QMenu",
+ "QInputDialog",
+ "QFileDialog",
+ "QColorDialog",
+ "QFontDialog",
+ "QProgressDialog",
+ ):
+ cls = getattr(QtWidgets, name, None)
+ if cls and hasattr(cls, "exec") and not hasattr(cls, "exec_"):
+ try:
+ setattr(cls, "exec_", _exec_wrapper)
+ except Exception:
+ pass
+ if QtGui:
+ cls = getattr(QtGui, "QDrag", None)
+ if cls and hasattr(cls, "exec") and not hasattr(cls, "exec_"):
+ try:
+ setattr(cls, "exec_", _exec_wrapper)
+ except Exception:
+ pass
+
+ if not hasattr(QtCore, "QSignalTransition"):
+ try:
+ if QT_API == "pyqt6":
+ import PyQt6.QtStateMachine as QtStateMachineMod # type: ignore
+ else:
+ import PySide6.QtStateMachine as QtStateMachineMod # type: ignore
+ q_signal_transition = getattr(QtStateMachineMod, "QSignalTransition", None)
+ if q_signal_transition is not None:
+ setattr(QtCore, "QSignalTransition", q_signal_transition)
+ except Exception:
+ pass
+
+
+def _binding_order(env_value: str) -> List[str]:
+ """Compute binding preference order based on env."""
+ value = (env_value or "auto").strip().lower()
+ if value in ("pyqt6", "pyside6", "pyqt5"):
+ return [value]
+ return ["pyqt6", "pyside6", "pyqt5"]
+
+
+def _import_binding(name: str) -> Tuple:
+ """Import a specific binding and return modules and helpers."""
+ if name == "pyqt6":
+ import PyQt6.QtCore as QtCoreMod
+ import PyQt6.QtGui as QtGuiMod
+ import PyQt6.QtWidgets as QtWidgetsMod
+ try:
+ import PyQt6.uic as uicMod
+ except Exception:
+ uicMod = None
+ try:
+ import PyQt6.QtStateMachine as QtStateMachineMod # type: ignore
+ q_state = getattr(QtStateMachineMod, "QState", None)
+ q_state_machine = getattr(QtStateMachineMod, "QStateMachine", None)
+ except Exception:
+ QtStateMachineMod = None
+ q_state = getattr(QtCoreMod, "QState", None)
+ q_state_machine = getattr(QtCoreMod, "QStateMachine", None)
+
+ if q_state is None or q_state_machine is None:
+ raise ImportError("PyQt6 QtStateMachine module not available (QState/QStateMachine missing)")
+ QtSvgMod = None
+ QtWebEngineCoreMod = None
+ QtWebEngineWidgetsMod = None
+ QtWebChannelMod = None
+ QtWebKitWidgetsMod = None
+ try:
+ import PyQt6.QtSvg as QtSvgMod # type: ignore
+ except Exception:
+ pass
+ try:
+ import PyQt6.QtWebEngineCore as QtWebEngineCoreMod # type: ignore
+ import PyQt6.QtWebEngineWidgets as QtWebEngineWidgetsMod # type: ignore
+ import PyQt6.QtWebChannel as QtWebChannelMod # type: ignore
+ except Exception:
+ pass
+ return (
+ "pyqt6",
+ QtCoreMod,
+ QtGuiMod,
+ QtWidgetsMod,
+ QtSvgMod,
+ QtWebEngineCoreMod,
+ QtWebEngineWidgetsMod,
+ QtWebChannelMod,
+ QtWebKitWidgetsMod,
+ QtCoreMod.pyqtSignal,
+ QtCoreMod.pyqtSlot,
+ QtCoreMod.pyqtProperty,
+ QtCoreMod.QRegularExpression,
+ q_state,
+ q_state_machine,
+ uicMod,
+ QtCoreMod.QT_VERSION_STR,
+ QtCoreMod.PYQT_VERSION_STR,
+ QtCoreMod.PYQT_VERSION_STR,
+ )
+
+ if name == "pyside6":
+ import PySide6.QtCore as QtCoreMod
+ import PySide6.QtGui as QtGuiMod
+ import PySide6.QtWidgets as QtWidgetsMod
+ QtUiToolsMod = None
+ try:
+ import PySide6.QtStateMachine as QtStateMachineMod # type: ignore
+ q_state = getattr(QtStateMachineMod, "QState", None)
+ q_state_machine = getattr(QtStateMachineMod, "QStateMachine", None)
+ except Exception:
+ QtStateMachineMod = None
+ q_state = getattr(QtCoreMod, "QState", None)
+ q_state_machine = getattr(QtCoreMod, "QStateMachine", None)
+
+ if q_state is None or q_state_machine is None:
+ raise ImportError("PySide6 QtStateMachine module not available (QState/QStateMachine missing)")
+ QtSvgMod = None
+ QtWebEngineCoreMod = None
+ QtWebEngineWidgetsMod = None
+ QtWebChannelMod = None
+ QtWebKitWidgetsMod = None
+ try:
+ import PySide6.QtSvg as QtSvgMod # type: ignore
+ except Exception:
+ pass
+ try:
+ import PySide6.QtWebEngineCore as QtWebEngineCoreMod # type: ignore
+ import PySide6.QtWebEngineWidgets as QtWebEngineWidgetsMod # type: ignore
+ import PySide6.QtWebChannel as QtWebChannelMod # type: ignore
+ except Exception:
+ pass
+ return (
+ "pyside6",
+ QtCoreMod,
+ QtGuiMod,
+ QtWidgetsMod,
+ QtSvgMod,
+ QtWebEngineCoreMod,
+ QtWebEngineWidgetsMod,
+ QtWebChannelMod,
+ QtWebKitWidgetsMod,
+ QtCoreMod.Signal,
+ QtCoreMod.Slot,
+ QtCoreMod.Property,
+ QtCoreMod.QRegularExpression,
+ q_state,
+ q_state_machine,
+ QtUiToolsMod,
+ QtCoreMod.__version__, # PySide binds Qt version here
+ QtCoreMod.__version__,
+ QtCoreMod.__version__,
+ )
+
+ if name == "pyqt5":
+ import PyQt5.QtCore as QtCoreMod
+ import PyQt5.QtGui as QtGuiMod
+ import PyQt5.QtWidgets as QtWidgetsMod
+ import PyQt5.uic as uicMod
+ q_state = getattr(QtCoreMod, "QState", None)
+ q_state_machine = getattr(QtCoreMod, "QStateMachine", None)
+ if q_state is None or q_state_machine is None:
+ raise ImportError("PyQt5 missing QState/QStateMachine in QtCore")
+
+ QtSvgMod = None
+ QtWebEngineCoreMod = None
+ QtWebEngineWidgetsMod = None
+ QtWebChannelMod = None
+ QtWebKitWidgetsMod = None
+ try:
+ import PyQt5.QtSvg as QtSvgMod # type: ignore
+ except Exception:
+ pass
+ try:
+ import PyQt5.QtWebEngineCore as QtWebEngineCoreMod # type: ignore
+ import PyQt5.QtWebEngineWidgets as QtWebEngineWidgetsMod # type: ignore
+ import PyQt5.QtWebChannel as QtWebChannelMod # type: ignore
+ except Exception:
+ pass
+ try:
+ import PyQt5.QtWebKitWidgets as QtWebKitWidgetsMod # type: ignore
+ except Exception:
+ pass
+ return (
+ "pyqt5",
+ QtCoreMod,
+ QtGuiMod,
+ QtWidgetsMod,
+ QtSvgMod,
+ QtWebEngineCoreMod,
+ QtWebEngineWidgetsMod,
+ QtWebChannelMod,
+ QtWebKitWidgetsMod,
+ QtCoreMod.pyqtSignal,
+ QtCoreMod.pyqtSlot,
+ QtCoreMod.pyqtProperty,
+ QtCoreMod.QRegularExpression,
+ q_state,
+ q_state_machine,
+ uicMod,
+ QtCoreMod.QT_VERSION_STR,
+ QtCoreMod.PYQT_VERSION_STR,
+ QtCoreMod.PYQT_VERSION_STR,
+ )
+
+ raise ImportError(f"Unknown binding '{name}'")
+
+
+def _select_binding() -> str:
+ """Select and load the first available binding."""
+ global QtCore, QtGui, QtWidgets, QtSvg, QtWebEngineCore, QtWebEngineWidgets, QtWebChannel, QtWebKitWidgets
+ global Signal, Slot, Property, QRegularExpression, QState, QStateMachine, uic, QT_API, QT_VERSION_STR, PYQT_VERSION_STR, BINDING_VERSION_STR, _MODULES
+ global _FAILED_IMPORT, _SELECTING
+
+ if _FAILED_IMPORT:
+ raise _FAILED_IMPORT
+ if _SELECTING:
+ # Prevent recursion if an import path triggers __getattr__ again
+ raise ImportError("qt_api: binding selection already in progress")
+ _SELECTING = True
+
+ requested = os.environ.get("OPENSHOT_QT_API", "auto")
+ attempts = _binding_order(requested)
+ errors = []
+ logger.info("qt_api: requested=%s, attempts=%s", requested, attempts)
+
+ for candidate in attempts:
+ try:
+ (
+ QT_API,
+ QtCore,
+ QtGui,
+ QtWidgets,
+ QtSvg,
+ QtWebEngineCore,
+ QtWebEngineWidgets,
+ QtWebChannel,
+ QtWebKitWidgets,
+ Signal,
+ Slot,
+ Property,
+ QRegularExpression,
+ QState,
+ QStateMachine,
+ uic,
+ QT_VERSION_STR,
+ PYQT_VERSION_STR,
+ BINDING_VERSION_STR,
+ ) = _import_binding(candidate)
+ logger.info(
+ "qt_api: selected %s (Qt %s, binding %s)",
+ QT_API,
+ QT_VERSION_STR,
+ BINDING_VERSION_STR,
+ )
+ _MODULES = [
+ m
+ for m in (
+ QtCore,
+ QtGui,
+ QtWidgets,
+ QtSvg,
+ QtWebEngineCore,
+ QtWebEngineWidgets,
+ QtWebChannel,
+ QtWebKitWidgets,
+ )
+ if m is not None
+ ]
+ _patch_enums_for_qt6()
+ _FAILED_IMPORT = None
+ _SELECTING = False
+ return QT_API
+ except Exception as ex: # noqa: BLE001
+ logger.warning("qt_api: failed to load %s: %s", candidate, ex)
+ errors.append(f"{candidate}: {ex}")
+
+ _SELECTING = False
+ _FAILED_IMPORT = ImportError(
+ "No suitable Qt binding found. Tried: "
+ + ", ".join(errors)
+ + ". Set OPENSHOT_QT_API to force a specific binding."
+ )
+ raise _FAILED_IMPORT
+
+
+def load_ui(path: str, baseinstance=None):
+ """Load a Qt Designer .ui file using the active binding."""
+ if QT_API is None:
+ _select_binding()
+
+ if QT_API in ("pyqt6", "pyqt5"):
+ from importlib import import_module
+
+ uic = import_module(f"{'PyQt6' if QT_API == 'pyqt6' else 'PyQt5'}.uic")
+ return uic.loadUi(path, baseinstance)
+
+ # PySide
+ from importlib import import_module
+
+ if QT_API != "pyside6":
+ raise RuntimeError(f"Unsupported Qt binding for load_ui(): {QT_API}")
+ QtUiTools = import_module("PySide6.QtUiTools") # type: ignore
+ if baseinstance is not None:
+ class UiLoader(QtUiTools.QUiLoader):
+ def __init__(self, base):
+ super().__init__(base)
+ self.base = base
+
+ def createWidget(self, class_name, parent=None, name=""):
+ if parent is None and self.base is not None:
+ return self.base
+ widget = super().createWidget(class_name, parent, name)
+ if self.base is not None and name:
+ setattr(self.base, name, widget)
+ return widget
+
+ def createAction(self, parent=None, name=""):
+ if parent is None and self.base is not None:
+ parent = self.base
+ action = super().createAction(parent, name)
+ if self.base is not None and name:
+ setattr(self.base, name, action)
+ return action
+
+ loader = UiLoader(baseinstance)
+ setattr(baseinstance, "_qt_ui_loader", loader)
+ else:
+ loader = QtUiTools.QUiLoader()
+ ui_file = QtCore.QFile(path)
+ if not ui_file.open(QtCore.QFile.ReadOnly):
+ raise IOError(f"Cannot open UI file: {path}")
+ try:
+ widget = loader.load(ui_file)
+ if baseinstance is not None:
+ if widget is not None and widget is not baseinstance:
+ setattr(baseinstance, "_qt_loaded_ui", widget)
+ main_window_type = getattr(QtWidgets, "QMainWindow", None)
+ if main_window_type and isinstance(baseinstance, main_window_type) and isinstance(widget, main_window_type):
+ central = widget.centralWidget()
+ if central is not None:
+ baseinstance.setCentralWidget(central)
+ menubar = widget.menuBar()
+ if menubar is not None:
+ baseinstance.setMenuBar(menubar)
+ statusbar = widget.statusBar()
+ if statusbar is not None:
+ baseinstance.setStatusBar(statusbar)
+ tool_bar_type = getattr(QtWidgets, "QToolBar", None)
+ if tool_bar_type:
+ for toolbar in widget.findChildren(tool_bar_type):
+ baseinstance.addToolBar(toolbar)
+ dock_type = getattr(QtWidgets, "QDockWidget", None)
+ if dock_type:
+ for dock in widget.findChildren(dock_type):
+ try:
+ area = widget.dockWidgetArea(dock)
+ except Exception:
+ area = None
+ if area is None or area == QtCore.Qt.NoDockWidgetArea:
+ baseinstance.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dock)
+ else:
+ baseinstance.addDockWidget(area, dock)
+ if widget is not None:
+ main_window_type = getattr(QtWidgets, "QMainWindow", None)
+ if main_window_type and isinstance(baseinstance, main_window_type):
+ root = baseinstance
+ else:
+ root = widget
+ else:
+ root = baseinstance
+ qaction_type = getattr(QtGui, "QAction", None)
+ actions = []
+ seen = set()
+ if qaction_type is not None:
+ for holder in (root, baseinstance, loader):
+ if holder is None:
+ continue
+ for act in holder.findChildren(qaction_type):
+ ident = id(act)
+ if ident in seen:
+ continue
+ seen.add(ident)
+ actions.append(act)
+ for act in actions:
+ try:
+ act.setParent(baseinstance)
+ except Exception:
+ pass
+ for obj in root.findChildren(QtCore.QObject):
+ obj_name = obj.objectName()
+ if obj_name and not hasattr(baseinstance, obj_name):
+ setattr(baseinstance, obj_name, obj)
+ if widget is not None and widget is not baseinstance:
+ widget_type = getattr(QtWidgets, "QWidget", None)
+ if widget_type and isinstance(baseinstance, widget_type) and isinstance(widget, widget_type):
+ try:
+ if baseinstance.layout() is None and widget.layout() is not None:
+ baseinstance.setLayout(widget.layout())
+ except Exception:
+ pass
+ return baseinstance
+ return widget
+ finally:
+ ui_file.close()
+
+
+def ensure_binding():
+ """Force binding selection (useful for early importers)."""
+ if QT_API is None:
+ _select_binding()
+ return QT_API
+
+
+def __getattr__(name):
+ """Lazy attribute forwarding so `from qt_api import QIcon` works."""
+ if QT_API is None:
+ _select_binding()
+ # Expose common QtCore symbols directly
+ if name in ("pyqtSignal", "Signal"):
+ return Signal
+ if name in ("pyqtSlot", "Slot"):
+ return Slot
+ if name in ("pyqtProperty", "Property"):
+ return Property
+ if name in ("QByteArray", "QLibraryInfo", "QDir"):
+ return getattr(QtCore, name)
+ if name in ("QState", "QStateMachine"):
+ global QState, QStateMachine
+ if QState is None or QStateMachine is None:
+ try:
+ if QT_API == "pyqt6":
+ import PyQt6.QtStateMachine as QtStateMachine # type: ignore
+ elif QT_API == "pyside6":
+ import PySide6.QtStateMachine as QtStateMachine # type: ignore
+ elif QT_API == "pyqt5":
+ QtStateMachine = QtCore
+ else:
+ QtStateMachine = QtCore
+ QState = getattr(QtStateMachine, "QState", None)
+ QStateMachine = getattr(QtStateMachine, "QStateMachine", None)
+ except Exception:
+ pass
+ return QState if name == "QState" else QStateMachine
+ if name == "QAbstractItemModelTester":
+ try:
+ if QT_API == "pyqt6":
+ import PyQt6.QtTest as QtTest # type: ignore
+ elif QT_API == "pyside6":
+ import PySide6.QtTest as QtTest # type: ignore
+ elif QT_API == "pyqt5":
+ import PyQt5.QtTest as QtTest # type: ignore
+ else:
+ QtTest = None
+ if QtTest is not None and hasattr(QtTest, "QAbstractItemModelTester"):
+ return QtTest.QAbstractItemModelTester
+ except Exception:
+ pass
+ for module in _MODULES:
+ if hasattr(module, name):
+ return getattr(module, name)
+ raise AttributeError(name)
+
+
+# Select binding immediately on import for visibility
+ensure_binding()
+
+__all__ = [
+ "QtCore",
+ "QtGui",
+ "QtWidgets",
+ "QtSvg",
+ "QtWebEngineCore",
+ "QtWebEngineWidgets",
+ "QtWebChannel",
+ "QtWebKitWidgets",
+ "Signal",
+ "Slot",
+ "Property",
+ "QRegularExpression",
+ "QState",
+ "QStateMachine",
+ # Commonly used Qt types
+ "QSignalTransition",
+ "QState",
+ "QStateMachine",
+ "QByteArray",
+ "QDir",
+ "QLibraryInfo",
+ "QT_API",
+ "QT_VERSION_STR",
+ "PYQT_VERSION_STR",
+ "BINDING_VERSION_STR",
+ "ensure_binding",
+ "load_ui",
+ "unwrapinstance",
+ "wrapinstance",
+ "isdeleted",
+ "modifiers_has",
+ "clear_override_cursor",
+]
diff --git a/src/tests/query_tests.py b/src/tests/query_tests.py
index c96b20df5..849bbebfa 100644
--- a/src/tests/query_tests.py
+++ b/src/tests/query_tests.py
@@ -33,11 +33,11 @@
import openshot
-from PyQt5.QtGui import QGuiApplication
+from qt_api import QGuiApplication
try:
# QtWebEngineWidgets must be loaded prior to creating a QApplication
# But on systems with only WebKit, this will fail (and we ignore the failure)
- from PyQt5.QtWebEngineWidgets import QWebEngineView # noqa
+ from qt_api import QWebEngineView # noqa
except ImportError:
pass
diff --git a/src/themes/base.py b/src/themes/base.py
index 6754555ad..cbf185589 100755
--- a/src/themes/base.py
+++ b/src/themes/base.py
@@ -27,10 +27,10 @@
import os
import re
-from PyQt5.QtCore import Qt, QSize
-from PyQt5.QtGui import QColor, QIcon, QPixmap, QPainter
-from PyQt5.QtSvg import QSvgRenderer
-from PyQt5.QtWidgets import QTabWidget, QWidget, QSizePolicy
+from qt_api import Qt, QSize
+from qt_api import QColor, QIcon, QPixmap, QPainter
+from qt_api import QSvgRenderer
+from qt_api import QTabWidget, QWidget, QSizePolicy
from classes import ui_util
from classes.info import PATH
@@ -181,8 +181,14 @@ def set_toolbar_buttons(self, toolbar, icon_size=24, settings=None):
"""Iterate through toolbar button settings, and apply them to each button.
[{"text": "", "icon": ""},...]
"""
- # List of colors for demonstration
- toolbar.clear()
+ from qt_api import QT_API, isdeleted
+
+ # Clear toolbar without deleting actions on PySide6
+ if QT_API == "pyside6":
+ for action in list(toolbar.actions()):
+ toolbar.removeAction(action)
+ else:
+ toolbar.clear()
# Set icon size
qsize_icon = QSize(icon_size, icon_size)
@@ -228,6 +234,8 @@ def set_toolbar_buttons(self, toolbar, icon_size=24, settings=None):
# Create button from action
if button_action:
+ if QT_API == "pyside6" and isdeleted(button_action):
+ continue
toolbar.addAction(button_action)
button_action.setVisible(button_visible)
button = toolbar.widgetForAction(button_action)
@@ -246,7 +254,7 @@ def apply_theme(self):
# Apply the stylesheet to the entire application
from classes import info
from classes.logger import log
- from PyQt5.QtGui import QFont, QFontDatabase
+ from qt_api import QFont, QFontDatabase
if not self.app.theme_manager:
log.warning("ThemeManager not initialized yet. Skip applying a theme.")
diff --git a/src/themes/cosmic/theme.py b/src/themes/cosmic/theme.py
index 912490a0b..d2a2d7c2b 100644
--- a/src/themes/cosmic/theme.py
+++ b/src/themes/cosmic/theme.py
@@ -27,9 +27,9 @@
import os
-from PyQt5.QtCore import Qt
-from PyQt5.QtGui import QIcon
-from PyQt5.QtWidgets import QTabWidget, QWidget
+from qt_api import Qt
+from qt_api import QIcon
+from qt_api import QTabWidget, QWidget
from classes.info import PATH
from ..base import BaseTheme
@@ -620,8 +620,8 @@ def apply_theme(self):
from classes.app import get_app
from classes import ui_util
from classes.logger import log
- from PyQt5.QtWidgets import QStyleFactory
- from PyQt5.QtGui import QFont
+ from qt_api import QStyleFactory
+ from qt_api import QFont
_ = get_app()._tr
diff --git a/src/themes/humanity/theme.py b/src/themes/humanity/theme.py
index 1e37e78c9..862fe798f 100644
--- a/src/themes/humanity/theme.py
+++ b/src/themes/humanity/theme.py
@@ -65,7 +65,7 @@ def apply_theme(self):
from classes import ui_util
from classes.logger import log
- from PyQt5.QtWidgets import QStyleFactory
+ from qt_api import QStyleFactory
log.info("Setting Fusion dark palette")
self.app.setStyle(QStyleFactory.create("Fusion"))
diff --git a/src/windows/about.py b/src/windows/about.py
index b1d2f87a6..b4eeb473a 100644
--- a/src/windows/about.py
+++ b/src/windows/about.py
@@ -30,8 +30,8 @@
import codecs
import re
-from PyQt5.QtCore import Qt, pyqtSignal
-from PyQt5.QtWidgets import QDialog
+from qt_api import Qt, pyqtSignal
+from qt_api import QDialog
from classes import info, ui_util
from classes.logger import log
@@ -85,7 +85,7 @@ class About(QDialog):
def __init__(self):
# Create dialog class
- QDialog.__init__(self)
+ super().__init__()
# Load UI from designer & init
ui_util.load_ui(self, self.ui_path)
@@ -304,7 +304,7 @@ class License(QDialog):
def __init__(self):
# Create dialog class
- QDialog.__init__(self)
+ super().__init__()
# Load UI from designer
ui_util.load_ui(self, self.ui_path)
@@ -336,7 +336,7 @@ class Credits(QDialog):
def __init__(self):
# Create dialog class
- QDialog.__init__(self)
+ super().__init__()
# Load UI from designer
ui_util.load_ui(self, self.ui_path)
@@ -429,7 +429,7 @@ class Changelog(QDialog):
def __init__(self):
# Create dialog class
- QDialog.__init__(self)
+ super().__init__()
# Load UI from designer
ui_util.load_ui(self, self.ui_path)
diff --git a/src/windows/add_to_timeline.py b/src/windows/add_to_timeline.py
index f11ea3e0c..4d2c929b9 100644
--- a/src/windows/add_to_timeline.py
+++ b/src/windows/add_to_timeline.py
@@ -31,9 +31,9 @@
from operator import itemgetter
from random import shuffle, randint, uniform
-from PyQt5.QtCore import QLocale
-from PyQt5.QtWidgets import QDialog
-from PyQt5.QtGui import QIcon
+from qt_api import QLocale
+from qt_api import QDialog
+from qt_api import QIcon
from classes import info, ui_util, time_parts
from classes.logger import log
@@ -437,7 +437,7 @@ def reject(self):
def __init__(self, files=None, position=0.0):
# Create dialog class
- QDialog.__init__(self)
+ super().__init__()
# Load UI from Designer
ui_util.load_ui(self, self.ui_path)
diff --git a/src/windows/animated_title.py b/src/windows/animated_title.py
index d9f34bcc0..cd2352431 100644
--- a/src/windows/animated_title.py
+++ b/src/windows/animated_title.py
@@ -29,8 +29,8 @@
import os
import uuid
-from PyQt5.QtCore import Qt
-from PyQt5.QtWidgets import (
+from qt_api import Qt
+from qt_api import (
QApplication, QDialog, QDialogButtonBox, QPushButton
)
@@ -95,8 +95,7 @@ def __init__(self, *args, **kwargs):
def _apply_tab_order(self):
"""Apply explicit tab order for animated title dialog."""
- from PyQt5.QtWidgets import QWidget
- from PyQt5.QtCore import QTimer
+ from qt_api import QWidget, QTimer
def do_tab_order():
# Force focus policies on buttons (something is resetting Cancel to NoFocus)
@@ -119,9 +118,9 @@ def do_tab_order():
# Apply tab order directly
for first, second in zip(ordered, ordered[1:]):
- QWidget.setTabOrder(first, second)
+ tabstops.safe_set_tab_order(first, second)
if len(ordered) >= 2:
- QWidget.setTabOrder(ordered[-1], ordered[0])
+ tabstops.safe_set_tab_order(ordered[-1], ordered[0])
QTimer.singleShot(0, do_tab_order)
diff --git a/src/windows/animation.py b/src/windows/animation.py
index 0cf7ab205..4cac27bf7 100644
--- a/src/windows/animation.py
+++ b/src/windows/animation.py
@@ -28,7 +28,7 @@
import os
-from PyQt5.QtWidgets import QDialog
+from qt_api import QDialog
from classes import info, ui_util
from classes.app import get_app
@@ -42,7 +42,7 @@ class Animation(QDialog):
def __init__(self):
# Create dialog class
- QDialog.__init__(self)
+ super().__init__()
# Load UI from designer
ui_util.load_ui(self, self.ui_path)
diff --git a/src/windows/color_picker.py b/src/windows/color_picker.py
index d36c4ac91..a78373a5b 100644
--- a/src/windows/color_picker.py
+++ b/src/windows/color_picker.py
@@ -25,10 +25,10 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5 import QtCore
-from PyQt5.QtWidgets import QColorDialog, QPushButton, QDialog, QFrame
-from PyQt5.QtGui import QColor, QPainter, QPen, QCursor
-from PyQt5.QtCore import Qt, QRect, QPoint
+from qt_api import QtCore
+from qt_api import QColorDialog, QPushButton, QDialog, QFrame
+from qt_api import QColor, QPainter, QPen, QCursor
+from qt_api import Qt, QRect, QPoint
from classes.logger import log
from classes.app import get_app
diff --git a/src/windows/cutting.py b/src/windows/cutting.py
index fd30a62c7..856b951bc 100644
--- a/src/windows/cutting.py
+++ b/src/windows/cutting.py
@@ -29,9 +29,9 @@
import functools
import json
-from PyQt5.QtCore import pyqtSignal, QTimer
-from PyQt5.QtWidgets import QDialog, QMessageBox, QSizePolicy, QSlider
-from PyQt5.QtCore import Qt, QEvent
+from qt_api import pyqtSignal, QTimer
+from qt_api import QDialog, QMessageBox, QSizePolicy, QSlider
+from qt_api import Qt, QEvent
import openshot # Python module for libopenshot (required video editing module installed separately)
from classes import info, ui_util, time_parts
@@ -63,7 +63,7 @@ def __init__(self, file=None, preview=False):
_ = get_app()._tr
# Create dialog class
- QDialog.__init__(self)
+ super().__init__()
# Load UI from designer
ui_util.load_ui(self, self.ui_path)
@@ -203,7 +203,7 @@ def __init__(self, file=None, preview=False):
self.initialized = True
def eventFilter(self, obj, event):
- if event.type() == event.KeyPress and obj is self.txtName:
+ if event.type() == QEvent.KeyPress and obj is self.txtName:
# Handle ENTER key to create new clip
if event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter:
if self.btnAddClip.isEnabled():
diff --git a/src/windows/export.py b/src/windows/export.py
index ebbf62814..524a65f26 100644
--- a/src/windows/export.py
+++ b/src/windows/export.py
@@ -42,11 +42,11 @@
from xml.parsers.expat import ExpatError
-from PyQt5.QtCore import Qt, QCoreApplication, QTimer, QSize, QPoint, pyqtSignal, pyqtSlot
-from PyQt5.QtWidgets import (
+from qt_api import Qt, QCoreApplication, QTimer, QSize, QPoint, pyqtSignal, pyqtSlot
+from qt_api import (
QMessageBox, QDialog, QFileDialog, QDialogButtonBox, QPushButton, QWidget, QLineEdit, QComboBox, QSpinBox, QCheckBox
)
-from PyQt5.QtGui import QIcon
+from qt_api import QIcon
from functools import partial
from classes import info, tabstops
from classes import ui_util
@@ -344,7 +344,7 @@ def _apply_and_wrap():
seen.add(widget)
for first, second in zip(ordered_unique, ordered_unique[1:]):
- QWidget.setTabOrder(first, second)
+ tabstops.safe_set_tab_order(first, second)
# Wrap back to the first field after the last visible button.
first_visible = ordered_unique[0] if ordered_unique else None
@@ -355,7 +355,7 @@ def _apply_and_wrap():
last_visible = None
if first_visible and last_visible:
- QWidget.setTabOrder(last_visible, first_visible)
+ tabstops.safe_set_tab_order(last_visible, first_visible)
self._tab_order_list = [
w for w in ordered_unique
@@ -1354,7 +1354,7 @@ def titlestring(sec, fps, mess):
self.close_button.setVisible(True)
# Make progress bar green (to indicate we are done)
- from PyQt5.QtGui import QPalette
+ from qt_api import QPalette
p = QPalette()
p.setColor(QPalette.Highlight, Qt.green)
self.progressExportVideo.setPalette(p)
diff --git a/src/windows/export_clips.py b/src/windows/export_clips.py
index 059a9c09e..41d8cb6bd 100644
--- a/src/windows/export_clips.py
+++ b/src/windows/export_clips.py
@@ -33,8 +33,8 @@
You should have received a copy of the GNU General Public License
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtWidgets import QPushButton, QDialog, QDialogButtonBox, QLabel, QFileDialog, QMessageBox
-from PyQt5.QtCore import Qt, QTimer
+from qt_api import QPushButton, QDialog, QDialogButtonBox, QLabel, QFileDialog, QMessageBox
+from qt_api import Qt, QTimer
from classes import ui_util
from classes import info
from classes.app import get_app
@@ -171,7 +171,7 @@ def _createWidgets(self):
self.cancel_button.clicked.connect(self._cancelButtonClicked)
# Make progress bar look like the one in the export dialog
- from PyQt5.QtGui import QPalette
+ from qt_api import QPalette
p = QPalette()
p.setColor(QPalette.Highlight, Qt.green)
self.progressExportVideo.setPalette(p)
diff --git a/src/windows/file_properties.py b/src/windows/file_properties.py
index 4f8c3265e..76de807ef 100644
--- a/src/windows/file_properties.py
+++ b/src/windows/file_properties.py
@@ -28,7 +28,7 @@
import os
import json
-from PyQt5.QtWidgets import (
+from qt_api import (
QDialog, QFileDialog, QDialogButtonBox, QPushButton,
)
@@ -54,7 +54,7 @@ def __init__(self, file):
self.file = file
# Create dialog class
- QDialog.__init__(self)
+ super().__init__()
# Load UI from designer
ui_util.load_ui(self, self.ui_path)
diff --git a/src/windows/main_window.py b/src/windows/main_window.py
index ae95cf06f..1f9e5e6f4 100644
--- a/src/windows/main_window.py
+++ b/src/windows/main_window.py
@@ -41,11 +41,11 @@
import threading
import openshot # Python module for libopenshot (required video editing module installed separately)
-from PyQt5.QtCore import (
+from qt_api import (
Qt, pyqtSignal, pyqtSlot, QCoreApplication, QTimer, QDateTime, QFileInfo, QEvent, QUrl, QLocale
)
-from PyQt5.QtGui import QIcon, QCursor, QKeySequence, QTextCursor
-from PyQt5.QtWidgets import (
+from qt_api import QIcon, QCursor, QKeySequence, QTextCursor
+from qt_api import (
QMainWindow, QWidget, QDockWidget,
QMessageBox, QDialog, QFileDialog, QInputDialog,
QAction, QActionGroup, QSizePolicy,
@@ -172,8 +172,16 @@ def closeEvent(self, event):
if self.shutting_down:
log.debug("Already shutting down, skipping the closeEvent() method")
return
- else:
- self.shutting_down = True
+
+ self._shutdown()
+
+ def _shutdown(self):
+ """Perform shutdown without prompting (used by closeEvent and app cleanup)."""
+ app = get_app()
+ if self.shutting_down:
+ log.debug("Already shutting down, skipping the shutdown routine")
+ return
+ self.shutting_down = True
# Log the exit routine
log.info('---------------- Shutting down -----------------')
@@ -198,12 +206,14 @@ def closeEvent(self, event):
# Stop timeline background workers (such as the thumbnail thread) before Qt
# begins destroying child widgets, to avoid QThread warnings on shutdown.
timeline_widget = getattr(self, "timeline", None)
+ from qt_api import isdeleted
if timeline_widget and getattr(timeline_widget, "thumbnail_manager", None):
log.info(
"Shutdown timeline thumbnail thread running=%s",
timeline_widget.thumbnail_manager._thread.isRunning(),
)
- timeline_widget.thumbnail_manager.shutdown()
+ if not isdeleted(timeline_widget.thumbnail_manager):
+ timeline_widget.thumbnail_manager.shutdown()
# Stop thumbnail server thread (if any)
if self.http_server_thread:
@@ -225,9 +235,10 @@ def closeEvent(self, event):
)
self.preview_thread.player.CloseAudioDevice()
self.preview_thread.kill()
- if self.videoPreview:
+ from qt_api import isdeleted
+ if self.videoPreview and not isdeleted(self.videoPreview):
self.videoPreview.deleteLater()
- self.videoPreview = None
+ self.videoPreview = None
self.preview_parent.Stop()
# Clean-up Timeline
@@ -2674,6 +2685,11 @@ def caption_editor_save(self):
def caption_editor_load(self, new_caption_text, caption_model_row):
"""Load the caption editor with text, or disable it if empty string detected"""
self.caption_model_row = caption_model_row
+ if self.captionTextEdit is None:
+ self.captionTextEdit = QTextEdit(self.dockCaptionContents)
+ self.captionTextEdit.setReadOnly(True)
+ self.tabCaptions.addWidget(self.captionTextEdit)
+ self.captionTextEdit.textChanged.connect(self.captionTextEdit_TextChanged)
self.captionTextEdit.setPlainText(new_caption_text.strip())
if not caption_model_row:
self.captionTextEdit.setReadOnly(True)
@@ -2859,6 +2875,7 @@ def save_settings(self):
# Get window settings from setting store
def load_settings(self):
s = get_app().get_settings()
+ from qt_api import QT_API
# Window state and geometry (also toolbar, dock locations and frozen UI state)
if s.get('window_geometry_v2'):
@@ -3072,7 +3089,7 @@ def setup_toolbars(self):
self.actionRedo.setEnabled(False)
# Add files toolbar
- self.filesToolbar = QToolBar("Files Toolbar")
+ self.filesToolbar = QToolBar("Files Toolbar", self.dockFilesContents)
self.filesToolbar.setObjectName("filesToolbar")
self.filesActionGroup = QActionGroup(self)
self.filesActionGroup.setExclusive(True)
@@ -3085,15 +3102,15 @@ def setup_toolbars(self):
self.filesToolbar.addAction(self.actionFilesShowVideo)
self.filesToolbar.addAction(self.actionFilesShowAudio)
self.filesToolbar.addAction(self.actionFilesShowImage)
- self.filesFilter = QLineEdit()
+ self.filesFilter = QLineEdit(self.filesToolbar)
self.filesFilter.setObjectName("filesFilter")
self.filesFilter.setPlaceholderText(_("Filter"))
self.filesFilter.setClearButtonEnabled(True)
self.filesToolbar.addWidget(self.filesFilter)
- self.tabFiles.layout().insertWidget(0, self.filesToolbar)
+ self.tabFiles.insertWidget(0, self.filesToolbar)
# Add transitions toolbar
- self.transitionsToolbar = QToolBar("Transitions Toolbar")
+ self.transitionsToolbar = QToolBar("Transitions Toolbar", self.dockTransitionsContents)
self.transitionsToolbar.setObjectName("transitionsToolbar")
self.transitionsActionGroup = QActionGroup(self)
self.transitionsActionGroup.setExclusive(True)
@@ -3102,17 +3119,17 @@ def setup_toolbars(self):
self.actionTransitionsShowAll.setChecked(True)
self.transitionsToolbar.addAction(self.actionTransitionsShowAll)
self.transitionsToolbar.addAction(self.actionTransitionsShowCommon)
- self.transitionsFilter = QLineEdit()
+ self.transitionsFilter = QLineEdit(self.transitionsToolbar)
self.transitionsFilter.setObjectName("transitionsFilter")
self.transitionsFilter.setPlaceholderText(_("Filter"))
self.transitionsFilter.setClearButtonEnabled(True)
self.transitionsToolbar.addWidget(self.transitionsFilter)
- self.tabTransitions.layout().addWidget(self.transitionsToolbar)
+ self.tabTransitions.addWidget(self.transitionsToolbar)
# Add effects toolbar
- self.effectsToolbar = QToolBar("Effects Toolbar")
+ self.effectsToolbar = QToolBar("Effects Toolbar", self.dockEffectsContents)
self.effectsToolbar.setObjectName("effectsToolbar")
- self.effectsFilter = QLineEdit()
+ self.effectsFilter = QLineEdit(self.effectsToolbar)
self.effectsActionGroup = QActionGroup(self)
self.effectsActionGroup.setExclusive(True)
self.effectsActionGroup.addAction(self.actionEffectsShowAll)
@@ -3126,40 +3143,40 @@ def setup_toolbars(self):
self.effectsFilter.setPlaceholderText(_("Filter"))
self.effectsFilter.setClearButtonEnabled(True)
self.effectsToolbar.addWidget(self.effectsFilter)
- self.tabEffects.layout().addWidget(self.effectsToolbar)
+ self.tabEffects.addWidget(self.effectsToolbar)
# Add emojis toolbar
- self.emojisToolbar = QToolBar("Emojis Toolbar")
+ self.emojisToolbar = QToolBar("Emojis Toolbar", self.dockEmojisContents)
self.emojisToolbar.setObjectName("emojisToolbar")
- self.emojiFilterGroup = QComboBox()
- self.emojisFilter = QLineEdit()
+ self.emojiFilterGroup = QComboBox(self.emojisToolbar)
+ self.emojisFilter = QLineEdit(self.emojisToolbar)
self.emojisFilter.setObjectName("emojisFilter")
self.emojisFilter.setPlaceholderText(_("Filter"))
self.emojisFilter.setClearButtonEnabled(True)
self.emojisToolbar.addWidget(self.emojiFilterGroup)
self.emojisToolbar.addWidget(self.emojisFilter)
- self.tabEmojis.layout().addWidget(self.emojisToolbar)
+ self.tabEmojis.addWidget(self.emojisToolbar)
# Add Video Preview toolbar
- self.videoToolbar = QToolBar("Video Toolbar")
+ self.videoToolbar = QToolBar("Video Toolbar", self.dockVideoContents)
self.videoToolbar.setObjectName("videoToolbar")
- self.tabVideo.layout().addWidget(self.videoToolbar)
+ self.tabVideo.addWidget(self.videoToolbar)
# Add Timeline toolbar
- self.timelineToolbar = QToolBar("Timeline Toolbar", self)
+ self.timelineToolbar = QToolBar("Timeline Toolbar", getattr(self, "dockTimelineContents", self))
self.timelineToolbar.setObjectName("timelineToolbar")
# Add Video Preview toolbar
- self.captionToolbar = QToolBar(_("Caption Toolbar"))
+ self.captionToolbar = QToolBar(_("Caption Toolbar"), self.dockCaptionContents)
# Add Caption text editor widget
- self.captionTextEdit = QTextEdit()
+ self.captionTextEdit = QTextEdit(self.dockCaptionContents)
self.captionTextEdit.setReadOnly(True)
# Playback controls (centered)
self.captionToolbar.addAction(self.actionInsertTimestamp)
- self.tabCaptions.layout().addWidget(self.captionToolbar)
- self.tabCaptions.layout().addWidget(self.captionTextEdit)
+ self.tabCaptions.addWidget(self.captionToolbar)
+ self.tabCaptions.addWidget(self.captionTextEdit)
# Hook up caption editor signal
self.captionTextEdit.textChanged.connect(self.captionTextEdit_TextChanged)
@@ -3183,7 +3200,8 @@ def setup_toolbars(self):
self.timelineToolbar.addWidget(self.sliderZoomWidget)
# Add timeline toolbar to web frame
- self.frameWeb.addWidget(self.timelineToolbar)
+ self.frameWeb.insertWidget(0, self.timelineToolbar)
+ self.timelineToolbar.show()
def clearSelections(self):
"""Clear all selection containers and reset preview transforms"""
@@ -3387,8 +3405,8 @@ def initModels(self):
self.filesTreeView = FilesTreeView(self.files_model)
self.filesListView = FilesListView(self.files_model)
self.files_model.update_model()
- self.tabFiles.layout().insertWidget(-1, self.filesTreeView)
- self.tabFiles.layout().insertWidget(-1, self.filesListView)
+ self.tabFiles.insertWidget(-1, self.filesTreeView)
+ self.tabFiles.insertWidget(-1, self.filesListView)
if s.get("file_view") == "details":
self.filesView = self.filesTreeView
self.filesListView.hide()
@@ -3406,8 +3424,8 @@ def initModels(self):
self.transitionsTreeView = TransitionsTreeView(self.transition_model)
self.transitionsListView = TransitionsListView(self.transition_model)
self.transition_model.update_model()
- self.tabTransitions.layout().insertWidget(-1, self.transitionsTreeView)
- self.tabTransitions.layout().insertWidget(-1, self.transitionsListView)
+ self.tabTransitions.insertWidget(-1, self.transitionsTreeView)
+ self.tabTransitions.insertWidget(-1, self.transitionsListView)
if s.get("transitions_view") == "details":
self.transitionsView = self.transitionsTreeView
self.transitionsListView.hide()
@@ -3425,8 +3443,8 @@ def initModels(self):
self.effectsTreeView = EffectsTreeView(self.effects_model)
self.effectsListView = EffectsListView(self.effects_model)
self.effects_model.update_model()
- self.tabEffects.layout().insertWidget(-1, self.effectsTreeView)
- self.tabEffects.layout().insertWidget(-1, self.effectsListView)
+ self.tabEffects.insertWidget(-1, self.effectsTreeView)
+ self.tabEffects.insertWidget(-1, self.effectsListView)
if s.get("effects_view") == "details":
self.effectsView = self.effectsTreeView
self.effectsListView.hide()
@@ -3443,7 +3461,7 @@ def initModels(self):
self.emojis_model = EmojisModel()
self.emojis_model.update_model()
self.emojiListView = EmojisListView(self.emojis_model)
- self.tabEmojis.layout().addWidget(self.emojiListView)
+ self.tabEmojis.addWidget(self.emojiListView)
def actionInsertKeyframe(self):
log.debug("actionInsertKeyframe")
@@ -3784,7 +3802,13 @@ def eventFilter(self, obj, event):
try:
sequences = get_app().window.getShortcutByName(action_name)
for sequence in sequences:
- if (sequence == QKeySequence(event.modifiers() | event.key())):
+ try:
+ modifiers = event.modifiers()
+ key = event.key()
+ combo = int(modifiers.value) | int(key) if hasattr(modifiers, "value") else int(modifiers) | int(key)
+ except Exception:
+ combo = event.modifiers() | event.key()
+ if (sequence == QKeySequence(combo)):
event.accept()
return True
except KeyError:
@@ -3802,7 +3826,11 @@ def eventFilter(self, obj, event):
# Get the shortcut key sequence
sequences = get_app().window.getShortcutByName(action_name)
for sequence in sequences:
- if (sequence == QKeySequence(event.modifiers() | event.key())):
+ if hasattr(event, "keyCombination"):
+ event_sequence = QKeySequence(event.keyCombination())
+ else:
+ event_sequence = QKeySequence(event.modifiers() | event.key())
+ if sequence == event_sequence:
event.accept()
return True
@@ -4069,7 +4097,10 @@ def __init__(self, *args):
# Setup timeline
self.timeline = TimelineView(self)
- self.frameWeb.layout().addWidget(self.timeline)
+ self.frameWeb.addWidget(self.timeline)
+ self.frameWeb.setStretch(0, 0)
+ self.frameWeb.setStretch(1, 1)
+ self.timeline.show()
# Configure the side docks to full-height
self.setCorner(Qt.TopLeftCorner, Qt.LeftDockWidgetArea)
@@ -4122,9 +4153,10 @@ def __init__(self, *args):
self.clearSelections()
# Setup video preview QWidget
- self.videoPreview = VideoWidget()
+ self.videoPreview = VideoWidget(self.dockVideoContents)
self.videoPreview.setObjectName("videoPreview")
- self.tabVideo.layout().insertWidget(0, self.videoPreview)
+ self.tabVideo.insertWidget(0, self.videoPreview)
+ self.videoPreview.show()
# Load window state and geometry
self.saved_state = None
diff --git a/src/windows/models/add_to_timeline_model.py b/src/windows/models/add_to_timeline_model.py
index f0ca01224..6596e246f 100644
--- a/src/windows/models/add_to_timeline_model.py
+++ b/src/windows/models/add_to_timeline_model.py
@@ -27,8 +27,8 @@
import os
-from PyQt5.QtCore import Qt
-from PyQt5.QtGui import QStandardItem, QStandardItemModel, QIcon
+from qt_api import Qt
+from qt_api import QStandardItem, QStandardItemModel, QIcon
from classes import info
from classes.logger import log
diff --git a/src/windows/models/blender_model.py b/src/windows/models/blender_model.py
index 950775def..1e038c1e0 100644
--- a/src/windows/models/blender_model.py
+++ b/src/windows/models/blender_model.py
@@ -33,8 +33,8 @@
except ImportError:
from xml.dom import minidom as xml
-from PyQt5.QtCore import Qt, QSortFilterProxyModel, QItemSelectionModel
-from PyQt5.QtGui import QIcon, QStandardItem, QStandardItemModel
+from qt_api import Qt, QSortFilterProxyModel, QItemSelectionModel
+from qt_api import QIcon, QStandardItem, QStandardItemModel
import openshot
diff --git a/src/windows/models/changelog_model.py b/src/windows/models/changelog_model.py
index 096146a70..66a60e0d4 100644
--- a/src/windows/models/changelog_model.py
+++ b/src/windows/models/changelog_model.py
@@ -25,8 +25,8 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import Qt, QSortFilterProxyModel, QItemSelectionModel
-from PyQt5.QtGui import QStandardItemModel, QStandardItem
+from qt_api import Qt, QSortFilterProxyModel, QItemSelectionModel
+from qt_api import QStandardItemModel, QStandardItem
from classes.logger import log
from classes.app import get_app
diff --git a/src/windows/models/credits_model.py b/src/windows/models/credits_model.py
index f7bafd38a..ce43fba4b 100644
--- a/src/windows/models/credits_model.py
+++ b/src/windows/models/credits_model.py
@@ -25,8 +25,8 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import Qt, QSortFilterProxyModel, QItemSelectionModel
-from PyQt5.QtGui import QIcon, QStandardItemModel, QStandardItem
+from qt_api import Qt, QSortFilterProxyModel, QItemSelectionModel
+from qt_api import QIcon, QStandardItemModel, QStandardItem
from classes.logger import log
from classes.app import get_app
diff --git a/src/windows/models/effects_model.py b/src/windows/models/effects_model.py
index abf2bda79..5bd523dd4 100644
--- a/src/windows/models/effects_model.py
+++ b/src/windows/models/effects_model.py
@@ -27,13 +27,11 @@
import os
-from PyQt5.QtCore import (
+from qt_api import (
QObject, QMimeData, Qt, QSize, pyqtSignal,
QSortFilterProxyModel, QPersistentModelIndex, QItemSelectionModel, QItemSelection, QModelIndex,
)
-from PyQt5.QtGui import QIcon, QPixmap, QStandardItemModel, QStandardItem
-from PyQt5.QtWidgets import QMessageBox
-
+from qt_api import QIcon, QPixmap, QStandardItemModel, QStandardItem
import openshot # Python module for libopenshot (required video editing module installed separately)
from classes import info
@@ -312,7 +310,7 @@ def __init__(self, *args):
if info.MODEL_TEST:
try:
# Create model tester objects
- from PyQt5.QtTest import QAbstractItemModelTester
+ from qt_api import QAbstractItemModelTester
self.model_tests = []
for m in [self.proxy_model, self.model]:
self.model_tests.append(
diff --git a/src/windows/models/emoji_model.py b/src/windows/models/emoji_model.py
index 254056102..2ceb63655 100644
--- a/src/windows/models/emoji_model.py
+++ b/src/windows/models/emoji_model.py
@@ -27,9 +27,9 @@
import os
-from PyQt5.QtCore import QMimeData, Qt, QSortFilterProxyModel, QModelIndex
-from PyQt5.QtGui import QStandardItemModel, QStandardItem, QIcon
-from PyQt5.QtWidgets import QMessageBox
+from qt_api import QObject, QMimeData, Qt, QSortFilterProxyModel, QModelIndex, pyqtSignal
+from qt_api import QStandardItemModel, QStandardItem, QIcon
+from qt_api import QMessageBox
import openshot # Python module for libopenshot (required video editing module installed separately)
from classes import info
@@ -65,7 +65,9 @@ def columnCount(self, parent=QModelIndex()):
return 1
-class EmojisModel():
+class EmojisModel(QObject):
+ ModelRefreshed = pyqtSignal()
+
def update_model(self, clear=True):
log.info("updating emoji model.")
app = get_app()
@@ -176,10 +178,24 @@ def update_model(self, clear=True):
if path not in self.model_paths:
self.model.appendRow(row)
self.model_paths[path] = path
+ self.ModelRefreshed.emit()
+
+ def set_text_filter(self, text):
+ pattern = text.replace(' ', '.*')
+ from qt_api import make_filter_regex, set_proxy_filter
+ regex = make_filter_regex(pattern, case_insensitive=True)
+ set_proxy_filter(self.proxy_model, regex)
+
+ def set_group_filter(self, group_id):
+ pattern = group_id or ""
+ from qt_api import make_filter_regex, set_proxy_filter
+ regex = make_filter_regex(pattern, case_insensitive=True)
+ set_proxy_filter(self.group_model, regex)
def __init__(self, *args):
# Create standard model
+ super().__init__(*args)
self.app = get_app()
self.model = EmojiStandardItemModel()
self.model.setColumnCount(3)
@@ -193,7 +209,7 @@ def __init__(self, *args):
self.group_model.setSortCaseSensitivity(Qt.CaseSensitive)
self.group_model.setSourceModel(self.model)
self.group_model.setSortLocaleAware(True)
- self.group_model.setFilterKeyColumn(1)
+ self.group_model.setFilterKeyColumn(2)
self.proxy_model = EmojiProxyModel()
self.proxy_model.setDynamicSortFilter(True)
@@ -201,13 +217,14 @@ def __init__(self, *args):
self.proxy_model.setSortCaseSensitivity(Qt.CaseSensitive)
self.proxy_model.setSourceModel(self.group_model)
self.proxy_model.setSortLocaleAware(True)
+ self.proxy_model.setFilterKeyColumn(-1)
# Attempt to load model testing interface, if requested
# (will only succeed with Qt 5.11+)
if info.MODEL_TEST:
try:
# Create model tester objects
- from PyQt5.QtTest import QAbstractItemModelTester
+ from qt_api import QAbstractItemModelTester
self.model_tests = []
for m in [self.proxy_model, self.group_model, self.model]:
self.model_tests.append(
diff --git a/src/windows/models/files_model.py b/src/windows/models/files_model.py
index d08bfcdad..a1b91d432 100644
--- a/src/windows/models/files_model.py
+++ b/src/windows/models/files_model.py
@@ -33,14 +33,14 @@
import functools
import uuid
-from PyQt5.QtCore import (
- QMimeData, Qt, pyqtSignal, QEventLoop, QObject,
+from qt_api import (
+ QMimeData, Qt, QUrl, pyqtSignal, QEventLoop, QObject,
QSortFilterProxyModel, QItemSelectionModel, QItemSelection, QPersistentModelIndex, QModelIndex
)
-from PyQt5.QtGui import (
+from qt_api import (
QIcon, QStandardItem, QStandardItemModel
)
-from PyQt5.QtWidgets import QAbstractItemView
+from qt_api import QAbstractItemView
from classes import updates
from classes import info
from classes.image_types import get_media_type
@@ -87,10 +87,13 @@ def data(self, index, role=Qt.DisplayRole):
def filterAcceptsRow(self, sourceRow, sourceParent):
"""Filter for text"""
+ from qt_api import isdeleted, get_proxy_filter_regex, regex_is_empty, regex_matches
+ files_filter = get_app().window.filesFilter
+ filter_text = "" if isdeleted(files_filter) else files_filter.text()
if get_app().window.actionFilesShowVideo.isChecked() \
or get_app().window.actionFilesShowAudio.isChecked() \
or get_app().window.actionFilesShowImage.isChecked() \
- or get_app().window.filesFilter.text():
+ or filter_text:
# Fetch the file name
index = self.sourceModel().index(sourceRow, 0, sourceParent)
file_name = self.sourceModel().data(index) # file name (i.e. MyVideo.mp4)
@@ -110,7 +113,11 @@ def filterAcceptsRow(self, sourceRow, sourceParent):
return False
# Match against regex pattern
- return self.filterRegExp().indexIn(file_name) >= 0 or self.filterRegExp().indexIn(tags) >= 0
+ regex = get_proxy_filter_regex(self)
+ if not regex_is_empty(regex):
+ tag_text = tags or ""
+ return regex_matches(regex, file_name) or regex_matches(regex, tag_text)
+ return True
# Continue running built-in parent filter logic
return super().filterAcceptsRow(sourceRow, sourceParent)
@@ -119,23 +126,51 @@ def mimeData(self, indexes):
# Create MimeData for drag operation
data = QMimeData()
- # Get list of all selected file ids
- ids = self.parent.selected_file_ids()
+ # Get list of selected file ids from indexes (more reliable across bindings)
+ ids = []
+ seen_rows = set()
+ for idx in indexes:
+ row = idx.row()
+ if row in seen_rows:
+ continue
+ seen_rows.add(row)
+ id_index = idx.sibling(row, 5)
+ file_id = id_index.data()
+ if file_id:
+ ids.append(file_id)
+ if not ids:
+ ids = self.model_owner.selected_file_ids()
data.setText(json.dumps(ids))
data.setHtml("clip")
+ urls = []
+ for file_id in ids:
+ try:
+ file = File.get(id=file_id)
+ except Exception:
+ file = None
+ if not file:
+ continue
+ try:
+ path = file.absolute_path()
+ except Exception:
+ path = file.data.get("path")
+ if path:
+ urls.append(QUrl.fromLocalFile(path))
+ if urls:
+ data.setUrls(urls)
# Return Mimedata
return data
def get_file_index(self, file_id):
# Find the index in the proxy model based on the file ID
- if file_id in self.parent.model_ids:
- return self.mapFromSource(QModelIndex(self.parent.model_ids[file_id]))
+ if file_id in self.model_owner.model_ids:
+ return self.mapFromSource(QModelIndex(self.model_owner.model_ids[file_id]))
return QModelIndex()
def __init__(self, **kwargs):
if "parent" in kwargs:
- self.parent = kwargs["parent"]
+ self.model_owner = kwargs["parent"]
kwargs.pop("parent")
# Call base class implementation
@@ -728,14 +763,14 @@ def __init__(self, *args):
functools.partial(self.update_model, clear=False))
# Call init for superclass QObject
- super(QObject, FilesModel).__init__(self, *args)
+ super().__init__(*args)
# Attempt to load model testing interface, if requested
# (will only succeed with Qt 5.11+)
if info.MODEL_TEST:
try:
# Create model tester objects
- from PyQt5.QtTest import QAbstractItemModelTester
+ from qt_api import QAbstractItemModelTester
self.model_tests = []
for m in [self.proxy_model, self.model]:
self.model_tests.append(
diff --git a/src/windows/models/profiles_model.py b/src/windows/models/profiles_model.py
index c2359793b..88aeefd09 100644
--- a/src/windows/models/profiles_model.py
+++ b/src/windows/models/profiles_model.py
@@ -25,8 +25,8 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import Qt, QSortFilterProxyModel
-from PyQt5.QtGui import QStandardItemModel, QStandardItem
+from qt_api import Qt, QSortFilterProxyModel
+from qt_api import QStandardItemModel, QStandardItem
from classes.logger import log
from classes.app import get_app
@@ -45,9 +45,16 @@ def filterAcceptsRow(self, sourceRow, sourceParent):
profile_dar = self.sourceModel().data(self.sourceModel().index(sourceRow, 5, sourceParent))
# Return, if regExp match in displayed format.
- return self.filterRegExp().indexIn(profile_key.lower()) >= 0 or \
- self.filterRegExp().indexIn(profile_desc.lower()) >= 0 or \
- self.filterRegExp().indexIn(profile_dar) >= 0
+ filter_re = None
+ if hasattr(self, "filterRegularExpression"):
+ filter_re = self.filterRegularExpression()
+ elif hasattr(self, "filterRegExp"):
+ filter_re = self.filterRegExp()
+ if filter_re is None:
+ return True
+ return filter_re.match(profile_key.lower()).hasMatch() or \
+ filter_re.match(profile_desc.lower()).hasMatch() or \
+ filter_re.match(profile_dar).hasMatch()
class ProfilesStandardItemModel(QStandardItemModel):
diff --git a/src/windows/models/properties_model.py b/src/windows/models/properties_model.py
index 916338146..a21ffd2f4 100644
--- a/src/windows/models/properties_model.py
+++ b/src/windows/models/properties_model.py
@@ -29,8 +29,8 @@
from collections import OrderedDict
from operator import itemgetter
-from PyQt5.QtCore import QMimeData, Qt, QLocale, QTimer
-from PyQt5.QtGui import (
+from qt_api import QMimeData, Qt, QLocale, QTimer
+from qt_api import (
QStandardItemModel, QStandardItem,
QPixmap, QColor,
)
diff --git a/src/windows/models/titles_model.py b/src/windows/models/titles_model.py
index b31ce8493..74d3bf830 100644
--- a/src/windows/models/titles_model.py
+++ b/src/windows/models/titles_model.py
@@ -28,9 +28,9 @@
import os
import fnmatch
-from PyQt5.QtCore import Qt, QObject, QMimeData, QSortFilterProxyModel, QItemSelectionModel, QLocale
-from PyQt5.QtGui import QStandardItemModel, QStandardItem, QIcon
-from PyQt5.QtWidgets import QMessageBox
+from qt_api import Qt, QObject, QMimeData, QSortFilterProxyModel, QItemSelectionModel, QLocale
+from qt_api import QStandardItemModel, QStandardItem, QIcon
+from qt_api import QMessageBox
import openshot # Python module for libopenshot (required video editing module installed separately)
from classes import info
@@ -201,7 +201,7 @@ def __init__(self, *args, **kwargs):
if info.MODEL_TEST:
try:
# Create model tester objects
- from PyQt5.QtTest import QAbstractItemModelTester
+ from qt_api import QAbstractItemModelTester
QAbstractItemModelTester(
self.model, QAbstractItemModelTester.FailureReportingMode.Warning)
log.info("Enabled model tests for title editor data")
diff --git a/src/windows/models/transition_model.py b/src/windows/models/transition_model.py
index 33fff35e8..d1fca7a5f 100644
--- a/src/windows/models/transition_model.py
+++ b/src/windows/models/transition_model.py
@@ -27,12 +27,12 @@
import os
-from PyQt5.QtCore import (
+from qt_api import (
QObject, QMimeData, Qt, pyqtSignal, QLocale,
QSortFilterProxyModel, QPersistentModelIndex, QItemSelectionModel, QItemSelection, QModelIndex,
)
-from PyQt5.QtGui import QIcon, QStandardItemModel, QStandardItem
-from PyQt5.QtWidgets import QMessageBox
+from qt_api import QIcon, QStandardItemModel, QStandardItem
+from qt_api import QMessageBox
import openshot # Python module for libopenshot (required video editing module installed separately)
from classes import info
@@ -367,7 +367,7 @@ def __init__(self, *args):
if info.MODEL_TEST:
try:
# Create model tester objects
- from PyQt5.QtTest import QAbstractItemModelTester
+ from qt_api import QAbstractItemModelTester
self.model_tests = []
for m in [self.proxy_model, self.model]:
self.model_tests.append(
diff --git a/src/windows/preferences.py b/src/windows/preferences.py
index 5de53ed1c..6f22d9cd0 100644
--- a/src/windows/preferences.py
+++ b/src/windows/preferences.py
@@ -31,14 +31,14 @@
import functools
import platform
-from PyQt5.QtCore import Qt, QSize, QDir
-from PyQt5.QtWidgets import (
- QWidget, QDialog, QMessageBox, QFileDialog,
+from qt_api import Qt, QSize, QDir
+from qt_api import (
+ QWidget, QDialog, QMessageBox, QFileDialog, QDialogButtonBox,
QVBoxLayout, QHBoxLayout, QSizePolicy,
QScrollArea, QLabel, QLineEdit, QPushButton,
QDoubleSpinBox, QComboBox, QCheckBox, QSpinBox, QStyle,
)
-from PyQt5.QtGui import QKeySequence, QIcon
+from qt_api import QKeySequence, QIcon
from classes import info, ui_util, tabstops
from classes import openshot_rc # noqa
@@ -59,7 +59,7 @@ class Preferences(QDialog):
def __init__(self):
# Create dialog class
- QDialog.__init__(self)
+ super().__init__()
# Load UI from designer
ui_util.load_ui(self, self.ui_path)
@@ -102,7 +102,7 @@ def __init__(self):
self.btnRestoreDefaults.setDefault(False)
# Make Close button the default so ENTER closes the dialog
- close_button = self.buttonBox.button(self.buttonBox.Close)
+ close_button = self.buttonBox.button(QDialogButtonBox.Close)
if close_button:
close_button.setDefault(True)
@@ -497,7 +497,11 @@ def _apply_tab_order(self):
tabstops.apply_auto_tab_order_later(self)
return
- ordered = [self.txtSearch, self.tabCategories]
+ # Ensure the scroll area is part of the focus chain (Qt6 is stricter)
+ if current_tab.focusProxy() is None and content_widget is not None:
+ current_tab.setFocusProxy(content_widget)
+
+ ordered = [self.txtSearch, self.tabCategories, current_tab]
ordered.extend(
tabstops.collect_focusable_from_layout(
content_widget.layout(), self, include_hidden=True
diff --git a/src/windows/preview_thread.py b/src/windows/preview_thread.py
index 07037f12b..7a2f5c052 100644
--- a/src/windows/preview_thread.py
+++ b/src/windows/preview_thread.py
@@ -26,11 +26,10 @@
"""
import time
-import sip
import math
-from PyQt5.QtCore import QObject, QThread, QTimer, pyqtSlot, pyqtSignal, QCoreApplication
-from PyQt5.QtWidgets import QMessageBox
+from qt_api import QObject, QThread, QTimer, pyqtSlot, pyqtSignal, QCoreApplication
+from qt_api import unwrapinstance, wrapinstance
import openshot # Python module for libopenshot (required video editing module installed separately)
from classes.app import get_app
@@ -209,6 +208,8 @@ def CheckAudioDevice(self):
# Convert float 'settings' sample rate to Integer, if detected
if type(s.get("default-samplerate")) == float:
+ if detected_sample_rate_int is None:
+ detected_sample_rate_int = round(s.get("default-samplerate"))
s.set("default-samplerate", detected_sample_rate_int)
# Convert float 'project' sample rate to Integer, if detected
@@ -282,8 +283,8 @@ def initPlayer(self):
# Get the address of the player's renderer (a QObject that emits signals when frames are ready)
self.renderer_address = self.player.GetRendererQObject()
- self.player.SetQWidget(sip.unwrapinstance(self.videoPreview))
- self.renderer = sip.wrapinstance(self.renderer_address, QObject)
+ self.player.SetQWidget(unwrapinstance(self.videoPreview))
+ self.renderer = wrapinstance(self.renderer_address, QObject)
self.videoPreview.connectSignals(self.renderer)
def kill(self):
diff --git a/src/windows/process_effect.py b/src/windows/process_effect.py
index 162c67f9a..c92d941e4 100644
--- a/src/windows/process_effect.py
+++ b/src/windows/process_effect.py
@@ -31,9 +31,9 @@
import functools
import webbrowser
-from PyQt5.QtCore import Qt, pyqtSignal, QCoreApplication
-from PyQt5.QtGui import QPainter
-from PyQt5.QtWidgets import QPushButton, QDialog, QLabel, QDoubleSpinBox, QSpinBox, QLineEdit, QCheckBox, QComboBox, QDialogButtonBox, QSizePolicy
+from qt_api import Qt, pyqtSignal, QCoreApplication
+from qt_api import QPainter
+from qt_api import QPushButton, QDialog, QLabel, QDoubleSpinBox, QSpinBox, QLineEdit, QCheckBox, QComboBox, QDialogButtonBox, QSizePolicy
import openshot # Python module for libopenshot (required video editing module installed separately)
from classes import info
@@ -75,7 +75,7 @@ def __init__(self, clip_id, effect_class, effect_params):
raise ModuleNotFoundError("Openshot not compiled with OpenCV")
# Create dialog class
- QDialog.__init__(self)
+ super().__init__()
# Track effect details
self.clip_id = clip_id
self.effect_name = ""
diff --git a/src/windows/profile.py b/src/windows/profile.py
index 375657ab4..d690ee4a0 100644
--- a/src/windows/profile.py
+++ b/src/windows/profile.py
@@ -30,8 +30,8 @@
import openshot # Python module for libopenshot (required video editing module installed separately)
-from PyQt5.QtCore import QTimer
-from PyQt5.QtWidgets import QDialog, QSizePolicy, QDialogButtonBox
+from qt_api import QTimer
+from qt_api import QDialog, QSizePolicy, QDialogButtonBox
from classes import info, ui_util, tabstops
from classes.app import get_app
@@ -49,7 +49,7 @@ class Profile(QDialog):
def __init__(self, initial_profile_desc=None):
# Create dialog class
- QDialog.__init__(self)
+ super().__init__()
# Load UI from designer & init
ui_util.load_ui(self, self.ui_path)
diff --git a/src/windows/profile_edit.py b/src/windows/profile_edit.py
index bb7683f15..63e134ba8 100644
--- a/src/windows/profile_edit.py
+++ b/src/windows/profile_edit.py
@@ -28,7 +28,7 @@
import os
import openshot
-from PyQt5.QtWidgets import QDialog, QMessageBox
+from qt_api import QDialog, QMessageBox
from classes import ui_util, info, tabstops
from classes.app import get_app
from classes.logger import log
@@ -41,7 +41,7 @@ class EditProfileDialog(QDialog):
ui_path = os.path.join(info.PATH, 'windows', 'ui', 'profile-edit.ui')
def __init__(self, profile, duplicate):
- super(EditProfileDialog, self).__init__()
+ super().__init__()
# Make copy of profile
self.original_profile = profile.Json()
diff --git a/src/windows/region.py b/src/windows/region.py
index f4e556d97..24eea48f5 100644
--- a/src/windows/region.py
+++ b/src/windows/region.py
@@ -30,8 +30,8 @@
import functools
import math
-from PyQt5.QtCore import *
-from PyQt5.QtWidgets import *
+from qt_api import *
+from qt_api import *
import openshot # Python module for libopenshot (required video editing module installed separately)
from classes import info, ui_util, time_parts, qt_types, updates
@@ -63,7 +63,7 @@ def __init__(self, file=None, clip=None):
_ = get_app()._tr
# Create dialog class
- QDialog.__init__(self)
+ super().__init__()
# Load UI from designer
ui_util.load_ui(self, self.ui_path)
@@ -277,6 +277,3 @@ def reject(self):
self.shutdownPlayer()
get_app().window.SelectRegionSignal.emit("")
super(SelectRegion, self).reject()
-
-
-
diff --git a/src/windows/title_editor.py b/src/windows/title_editor.py
index f9a53ea0b..a5dcaf941 100644
--- a/src/windows/title_editor.py
+++ b/src/windows/title_editor.py
@@ -40,9 +40,9 @@
# Is one even necessary, or is it safe to use xml.dom.minidom for that?
from xml.dom import minidom
-from PyQt5.QtCore import Qt, pyqtSlot, QTimer, pyqtSignal, QRect, QPoint, QSize, QEvent
-from PyQt5.QtGui import QFontDatabase, QColor, QIcon, QFont, QFontInfo, QPixmap, QPainter
-from PyQt5.QtWidgets import (
+from qt_api import Qt, pyqtSlot, QTimer, pyqtSignal, QRect, QPoint, QSize, QEvent
+from qt_api import QFontDatabase, QColor, QIcon, QFont, QFontInfo, QPixmap, QPainter
+from qt_api import (
QWidget,
QMessageBox, QDialog, QColorDialog, QFontDialog,
QPushButton, QLineEdit, QLabel, QDialogButtonBox
@@ -91,8 +91,11 @@ def __init__(self, *args, edit_file_path=None, duplicate=False, **kwargs):
# Create dialog class
super().__init__(*args, **kwargs)
- # Init font DB
- self.font_db = QFontDatabase()
+ # Init font DB (Qt6 removes the default constructor)
+ try:
+ self.font_db = QFontDatabase()
+ except TypeError:
+ self.font_db = QFontDatabase
# A timer to pause until user input stops before updating the svg
self.update_timer = QTimer(self)
@@ -555,7 +558,9 @@ def _apply_tab_order(self):
)
if ordered:
- QTimer.singleShot(0, lambda: QWidget.setTabOrder(ordered[-1], ordered[0]))
+ QTimer.singleShot(
+ 0, lambda: tabstops.safe_set_tab_order(ordered[-1], ordered[0])
+ )
def writeToFile(self, xmldoc):
'''writes a new svg file containing the user edited data'''
diff --git a/src/windows/ui/preferences.ui b/src/windows/ui/preferences.ui
index 46db7aecc..ea2469cfe 100644
--- a/src/windows/ui/preferences.ui
+++ b/src/windows/ui/preferences.ui
@@ -50,11 +50,17 @@
-
-
-
- Qt::Horizontal
-
-
+
+
+ Qt::Horizontal
+
+
+
+ 0
+ 0
+
+
+
-
diff --git a/src/windows/video_widget.py b/src/windows/video_widget.py
index 3bf4ff826..a83a27540 100644
--- a/src/windows/video_widget.py
+++ b/src/windows/video_widget.py
@@ -30,14 +30,16 @@
import time
import uuid
-from PyQt5.QtCore import (
+from qt_api import (
Qt, QCoreApplication, QMutex, QTimer,
- QPoint, QPointF, QSize, QSizeF, QRect, QRectF,
+ QPoint, QPointF, QSize, QSizeF, QRect, QRectF, QLineF,
)
-from PyQt5.QtGui import (
+from qt_api import QT_API
+from qt_api import modifiers_has
+from qt_api import (
QTransform, QPainter, QIcon, QColor, QPen, QBrush, QCursor, QImage, QRegion
)
-from PyQt5.QtWidgets import QSizePolicy, QWidget, QPushButton
+from qt_api import QSizePolicy, QWidget, QPushButton
import openshot # Python module for libopenshot (required video editing module installed separately)
@@ -361,7 +363,10 @@ def drawTransformHandler(
center = origin_rect.center()
halfW = QPointF(origin_rect.width() * 0.75, 0)
halfH = QPointF(0, origin_rect.height() * 0.75)
- painter.drawLines(center - halfW, center + halfW, center - halfH, center + halfH)
+ painter.drawLines([
+ QLineF(center - halfW, center + halfW),
+ QLineF(center - halfH, center + halfH),
+ ])
painter.resetTransform()
@@ -644,7 +649,10 @@ def paintEvent(self, event, *args):
cross_h = self.cropOriginHandleScreen.height() * 0.75
halfW = QPointF(cross_w / 2.0, 0)
halfH = QPointF(0, cross_h / 2.0)
- painter.drawLines(c - halfW, c + halfW, c - halfH, c + halfH)
+ painter.drawLines([
+ QLineF(c - halfW, c + halfW),
+ QLineF(c - halfH, c + halfH),
+ ])
# Region selection UI (also uses global opacity)
if self.region_enabled:
@@ -838,6 +846,13 @@ def mouseReleaseEvent(self, event):
def rotateCursor(self, pixmap, rotation, shear_x, shear_y):
"""Rotate cursor based on the current transform"""
+ fallback = None
+ if isinstance(pixmap, tuple):
+ pixmap, fallback = pixmap
+ if pixmap is None or pixmap.isNull():
+ if fallback is None:
+ fallback = Qt.ArrowCursor
+ return QCursor(fallback)
rotated_pixmap = pixmap.transformed(
QTransform().rotate(rotation).shear(shear_x, shear_y).scale(0.8, 0.8),
Qt.SmoothTransformation)
@@ -1164,7 +1179,7 @@ def mouseMoveEvent(self, event):
elif self.transform_mode == 'scale_right':
scale_x += x_motion / half_w
- if int(QCoreApplication.instance().keyboardModifiers() & Qt.ControlModifier) > 0:
+ if modifiers_has(QCoreApplication.instance().keyboardModifiers(), Qt.ControlModifier):
# If CTRL key is pressed, fix the scale_y to the correct aspect ratio
if scale_x:
scale_y = scale_x
@@ -1375,7 +1390,7 @@ def mouseMoveEvent(self, event):
elif self.transform_mode == 'scale_right':
scale_x += x_motion / half_w
- if int(QCoreApplication.instance().keyboardModifiers() & Qt.ControlModifier) > 0:
+ if modifiers_has(QCoreApplication.instance().keyboardModifiers(), Qt.ControlModifier):
# If CTRL key is pressed, fix the scale_y to the correct aspect ratio
if scale_x:
scale_y = scale_x
@@ -1983,7 +1998,7 @@ def __init__(self, watch_project=True, *args):
"""watch_project: watch for changes in project size / widget size, and
continue to match the current project's aspect ratio."""
# Invoke parent init
- QWidget.__init__(self, *args)
+ super().__init__(*args)
# Translate object
_ = get_app()._tr
@@ -2052,6 +2067,17 @@ def __init__(self, watch_project=True, *args):
# Load icon (using display DPI)
self.cursors = {}
+ cursor_fallbacks = {
+ "move": Qt.SizeAllCursor,
+ "resize_x": Qt.SizeHorCursor,
+ "resize_y": Qt.SizeVerCursor,
+ "resize_bdiag": Qt.SizeBDiagCursor,
+ "resize_fdiag": Qt.SizeFDiagCursor,
+ "rotate": Qt.CrossCursor,
+ "shear_x": Qt.SizeHorCursor,
+ "shear_y": Qt.SizeVerCursor,
+ "hand": Qt.OpenHandCursor,
+ }
for cursor_name in ["move",
"resize_x",
"resize_y",
@@ -2062,7 +2088,10 @@ def __init__(self, watch_project=True, *args):
"shear_y",
"hand"]:
icon = QIcon(":/cursors/cursor_%s.png" % cursor_name)
- self.cursors[cursor_name] = icon.pixmap(32, 32)
+ pixmap = icon.pixmap(32, 32)
+ if pixmap.isNull() or pixmap.size().isEmpty():
+ pixmap = None
+ self.cursors[cursor_name] = (pixmap, cursor_fallbacks[cursor_name])
# Mutex lock
self.mutex = QMutex()
diff --git a/src/windows/views/add_to_timeline_treeview.py b/src/windows/views/add_to_timeline_treeview.py
index f1258ef5c..5ad869b80 100644
--- a/src/windows/views/add_to_timeline_treeview.py
+++ b/src/windows/views/add_to_timeline_treeview.py
@@ -25,8 +25,8 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QSize
-from PyQt5.QtWidgets import QTreeView, QAbstractItemView
+from qt_api import QSize
+from qt_api import QTreeView, QAbstractItemView
from classes import info
from classes.app import get_app
diff --git a/src/windows/views/blender_listview.py b/src/windows/views/blender_listview.py
index 8794eece1..3bfed0a7b 100644
--- a/src/windows/views/blender_listview.py
+++ b/src/windows/views/blender_listview.py
@@ -41,14 +41,14 @@
except ImportError:
from xml.dom import minidom as xml
-from PyQt5.QtCore import (
+from qt_api import (
Qt, QObject, pyqtSlot, pyqtSignal, QThread, QTimer, QSize,
)
-from PyQt5.QtWidgets import (
+from qt_api import (
QApplication, QListView, QMessageBox,
QComboBox, QDoubleSpinBox, QLabel, QPushButton, QLineEdit, QPlainTextEdit,
)
-from PyQt5.QtGui import QColor, QImage, QPixmap, QIcon
+from qt_api import QColor, QImage, QPixmap, QIcon
from classes import info
from classes.logger import log
diff --git a/src/windows/views/changelog_treeview.py b/src/windows/views/changelog_treeview.py
index 7f434ae2f..de125cbe1 100644
--- a/src/windows/views/changelog_treeview.py
+++ b/src/windows/views/changelog_treeview.py
@@ -29,9 +29,9 @@
import webbrowser
from functools import partial
-from PyQt5.QtCore import Qt, QRegExp
-from PyQt5.QtWidgets import QListView, QTreeView, QAbstractItemView, QSizePolicy, QHeaderView, QApplication
-from PyQt5.QtGui import QCursor
+from qt_api import Qt, QRegularExpression
+from qt_api import QListView, QTreeView, QAbstractItemView, QSizePolicy, QHeaderView, QApplication
+from qt_api import QCursor
from classes.logger import log
from classes.app import get_app
@@ -58,7 +58,7 @@ def refresh_view(self):
def filter_changed(self, text=""):
"""Apply filter text to proxy model"""
- self.model().setFilterRegExp(QRegExp(text, Qt.CaseInsensitive))
+ self.model().setFilterRegularExpression(QRegularExpression(text, QRegularExpression.CaseInsensitiveOption))
self.model().setFilterKeyColumn(-1)
self.model().sort(1, Qt.AscendingOrder)
diff --git a/src/windows/views/credits_treeview.py b/src/windows/views/credits_treeview.py
index 0a58b6f00..5d6c6394a 100644
--- a/src/windows/views/credits_treeview.py
+++ b/src/windows/views/credits_treeview.py
@@ -27,9 +27,9 @@
"""
import webbrowser
-from PyQt5.QtCore import Qt, QRegExp
-from PyQt5.QtWidgets import QListView, QTreeView, QAbstractItemView, QSizePolicy, QHeaderView, QApplication
-from PyQt5.QtGui import QCursor
+from qt_api import Qt, QRegularExpression
+from qt_api import QListView, QTreeView, QAbstractItemView, QSizePolicy, QHeaderView, QApplication
+from qt_api import QCursor
from functools import partial
from classes.logger import log
@@ -64,7 +64,7 @@ def refresh_view(self):
def filter_changed(self, text=""):
"""Apply filter text to proxy model"""
- self.model().setFilterRegExp(QRegExp(text, Qt.CaseInsensitive))
+ self.model().setFilterRegularExpression(QRegularExpression(text, QRegularExpression.CaseInsensitiveOption))
self.model().setFilterKeyColumn(-1)
self.model().sort(2, Qt.AscendingOrder)
diff --git a/src/windows/views/effects_listview.py b/src/windows/views/effects_listview.py
index 771da608b..b11a8f9b5 100644
--- a/src/windows/views/effects_listview.py
+++ b/src/windows/views/effects_listview.py
@@ -25,9 +25,10 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QSize, QPoint, Qt, QRegExp
-from PyQt5.QtGui import QDrag
-from PyQt5.QtWidgets import QListView, QAbstractItemView
+from qt_api import QSize, QPoint, Qt
+from qt_api import clear_override_cursor
+from qt_api import QDrag
+from qt_api import QListView, QAbstractItemView
from classes import info
from classes.app import get_app
@@ -47,7 +48,7 @@ def contextMenuEvent(self, event):
menu = StyledContextMenu(parent=self)
menu.addAction(self.win.actionDetailsView)
- menu.popup(event.globalPos())
+ menu.show_at(event)
def startDrag(self, event):
""" Override startDrag method to display custom icon """
@@ -73,7 +74,11 @@ def startDrag(self, event):
drag.setMimeData(self.model().mimeData(selected))
drag.setPixmap(icon.pixmap(self.drag_item_size))
drag.setHotSpot(self.drag_item_center)
- drag.exec_()
+ exec_fn = getattr(drag, "exec", None) or getattr(drag, "exec_", None)
+ if exec_fn is None:
+ raise AttributeError("QDrag has no exec_/exec method")
+ exec_fn()
+ clear_override_cursor()
def filter_changed(self):
self.refresh_view()
@@ -81,10 +86,12 @@ def filter_changed(self):
def refresh_view(self):
"""Filter transitions with proxy class"""
filter_text = self.win.effectsFilter.text()
- # Apply filter to the source proxy model (not the single-column wrapper)
- self.effects_model.proxy_model.setFilterRegExp(QRegExp(filter_text.replace(' ', '.*')))
+ from qt_api import make_filter_regex, set_proxy_filter
+ pattern = filter_text.replace(' ', '.*')
+ regex = make_filter_regex(pattern, case_insensitive=True)
+ set_proxy_filter(self.effects_model.proxy_model, regex)
self.effects_model.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive)
- self.effects_model.proxy_model.sort(Qt.AscendingOrder)
+ self.effects_model.proxy_model.sort(0, Qt.AscendingOrder)
def __init__(self, model):
# Invoke parent init
@@ -108,6 +115,8 @@ def __init__(self, model):
self.selectionModel().deleteLater()
self.setSelectionMode(QAbstractItemView.SingleSelection)
self.setSelectionBehavior(QAbstractItemView.SelectRows)
+ if hasattr(self, "setSelectionRectVisible"):
+ self.setSelectionRectVisible(False)
self.setSelectionModel(self.effects_model.list_selection_model)
# Setup header columns
diff --git a/src/windows/views/effects_treeview.py b/src/windows/views/effects_treeview.py
index 4e0ec8acc..8dfa7cc03 100644
--- a/src/windows/views/effects_treeview.py
+++ b/src/windows/views/effects_treeview.py
@@ -25,9 +25,10 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import Qt, QSize, QPoint
-from PyQt5.QtGui import QDrag
-from PyQt5.QtWidgets import QTreeView, QAbstractItemView, QSizePolicy
+from qt_api import Qt, QSize, QPoint
+from qt_api import clear_override_cursor
+from qt_api import QDrag
+from qt_api import QTreeView, QAbstractItemView, QSizePolicy
from classes import info
from classes.app import get_app
@@ -48,7 +49,7 @@ def contextMenuEvent(self, event):
menu = StyledContextMenu(parent=self)
menu.addAction(self.win.actionThumbnailView)
- menu.popup(event.globalPos())
+ menu.show_at(event)
def startDrag(self, supportedActions):
""" Override startDrag method to display custom icon """
@@ -74,7 +75,11 @@ def startDrag(self, supportedActions):
drag.setMimeData(self.model().mimeData(selected))
drag.setPixmap(icon.pixmap(self.drag_item_size))
drag.setHotSpot(self.drag_item_center)
- drag.exec_()
+ exec_fn = getattr(drag, "exec", None) or getattr(drag, "exec_", None)
+ if exec_fn is None:
+ raise AttributeError("QDrag has no exec_/exec method")
+ exec_fn()
+ clear_override_cursor()
def refresh_columns(self):
"""Hide certain columns"""
diff --git a/src/windows/views/emojis_listview.py b/src/windows/views/emojis_listview.py
index 5394f033c..53b4b6bb3 100644
--- a/src/windows/views/emojis_listview.py
+++ b/src/windows/views/emojis_listview.py
@@ -25,10 +25,9 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QMimeData, QSize, QPoint, Qt, pyqtSlot, QRegExp
-from PyQt5.QtGui import QDrag
-from PyQt5.QtWidgets import QListView
-
+from qt_api import QMimeData, QSize, QPoint, Qt, QUrl, pyqtSlot
+from qt_api import clear_override_cursor
+from qt_api import QDrag, QListView
import openshot # Python module for libopenshot (required video editing module installed separately)
from classes import info
from classes.query import File
@@ -77,20 +76,33 @@ def startDrag(self, event):
# Start a transaction so File + Clip are grouped for undo
tid = str(uuid.uuid4())
get_app().updates.transaction_id = tid
-
- file = self.add_file(data[0], emoji_name)
-
- # Update mimedata for emoji
- data = QMimeData()
- data.setText(json.dumps([file.id]))
- data.setHtml("clip")
- drag.setMimeData(data)
-
- # Start drag
- drag.exec_()
-
- # End transaction
- get_app().updates.transaction_id = None
+ try:
+ file = self.add_file(data[0], emoji_name)
+ if not file:
+ log.warning("Failed to add emoji file for drag: %s", data[0])
+ return
+
+ # Update mimedata for emoji
+ data = QMimeData()
+ data.setText(json.dumps([file.id]))
+ data.setHtml("clip")
+ try:
+ data.setUrls([QUrl.fromLocalFile(file.absolute_path())])
+ except Exception:
+ file_path = file.data.get("path")
+ if file_path:
+ data.setUrls([QUrl.fromLocalFile(file_path)])
+ drag.setMimeData(data)
+
+ # Start drag
+ exec_fn = getattr(drag, "exec", None) or getattr(drag, "exec_", None)
+ if exec_fn is None:
+ raise AttributeError("QDrag has no exec_/exec method")
+ exec_fn()
+ clear_override_cursor()
+ finally:
+ # End transaction
+ get_app().updates.transaction_id = None
def add_file(self, filepath, emoji_name=None):
# Add file into project
@@ -132,46 +144,60 @@ def add_file(self, filepath, emoji_name=None):
# Log exception
log.warning("Failed to import file: {}".format(str(ex)))
- @pyqtSlot(int)
- def group_changed(self, index=-1):
- emoji_group_name = get_app().window.emojiFilterGroup.itemText(index)
- emoji_group_id = get_app().window.emojiFilterGroup.itemData(index)
- self.group_model.setFilterFixedString(emoji_group_id)
- self.group_model.setFilterKeyColumn(2)
- # Save current emoji filter to settings
+ def filter_changed(self, text):
+ self.emojis_model.set_text_filter(text)
+
+ def group_changed(self, index):
+ group_id = self.win.emojiFilterGroup.itemData(index)
+ self.emojis_model.set_group_filter(group_id or "")
s = get_app().get_settings()
- setting_emoji_group_id = s.get('emoji_group_filter') or 'smileys-emotion'
- if setting_emoji_group_id != emoji_group_id:
- s.set('emoji_group_filter', emoji_group_id)
+ if s.get("emoji_group_filter") != group_id:
+ s.set("emoji_group_filter", group_id)
- self.refresh_view()
+ def refresh_view(self):
+ """Filter emojis with proxy class"""
- @pyqtSlot(str)
- def filter_changed(self, filter_text=None):
- """Filter emoji with proxy class"""
+ col = self.model.sortColumn()
+ self.model.sort(col)
- self.model.setFilterRegExp(QRegExp(filter_text, Qt.CaseInsensitive))
- self.model.setFilterKeyColumn(0)
- self.refresh_view()
+ def resize_contents(self):
+ pass
- def refresh_view(self):
- # Sort by column 0
- self.model.sort(0)
+ @pyqtSlot()
+ def clicked(self, index):
+ """If any emoji clicked, set that emoji on the project"""
+ # Get selected emoji file_path
+ index = index.sibling(index.row(), 5)
+ file_path = self.model.data(index, Qt.DisplayRole)
- def __init__(self, model):
+ # Add emoji to project (after checking if not found in project)
+ if file_path not in info.EMOJI_FILES:
+ self.add_file(file_path)
+
+ # Set emoji file in preferences (displayed on project actions)
+ info.PREFERENCES.set("emoji", file_path)
+ info.EMOJI_PATH = file_path
+ info.EMOJI_ICON = file_path
+
+ def __init__(self, model, *args):
# Invoke parent init
- QListView.__init__(self)
+ super().__init__(*args)
- # Get external references
- app = get_app()
- _ = app._tr
- self.win = app.window
+ # Get a reference to the window object
+ self.win = get_app().window
- # Get Model data
+ # Set model (expects a proxy model)
self.emojis_model = model
self.group_model = self.emojis_model.group_model
self.model = self.emojis_model.proxy_model
+ self.setModel(self.model)
+
+ # Configure selection behavior
+ self.setSelectionMode(QListView.SingleSelection)
+ self.setSelectionBehavior(QListView.SelectRows)
+ if hasattr(self, "setSelectionRectVisible"):
+ self.setSelectionRectVisible(False)
# Keep track of mouse press start position to determine when to start drag
self.setAcceptDrops(True)
@@ -179,35 +205,26 @@ def __init__(self, model):
self.setDropIndicatorShown(True)
# Setup header columns and layout
- self.setModel(self.model)
self.setIconSize(self.emoji_icon_size)
self.setGridSize(self.emoji_grid_size)
self.setViewMode(QListView.IconMode)
self.setResizeMode(QListView.Adjust)
self.setUniformItemSizes(True)
self.setWordWrap(False)
+ self.setTextElideMode(Qt.ElideRight)
- # Initialize sort
- self.refresh_view()
-
- # Get default emoji filter group
- s = get_app().get_settings()
- default_group_id = s.get('emoji_group_filter') or 'smileys-emotion'
-
- # setup filter events
+ self.emojis_model.ModelRefreshed.connect(self.refresh_view)
+ # Activate filter and group selection
+ _ = get_app()._tr
self.win.emojisFilter.textChanged.connect(self.filter_changed)
-
- # Loop through emoji groups, and populate emoji filter drop-down
- self.win.emojiFilterGroup.clear()
- self.win.emojiFilterGroup.addItem(_("Show All"), "")
+ s = get_app().get_settings()
+ default_group_id = s.get("emoji_group_filter") or "smileys-emotion"
dropdown_index = 0
- for index, emoji_group_tuple in enumerate(sorted(self.emojis_model.emoji_groups)):
- emoji_group_name, emoji_group_id = emoji_group_tuple
- self.win.emojiFilterGroup.addItem(emoji_group_name, emoji_group_id)
- if emoji_group_id == default_group_id:
- # Initialize emoji filter group to settings
- # Off by one, due to 'show all' choice above
+ self.win.emojiFilterGroup.clear()
+ self.win.emojiFilterGroup.addItem(_("All"), "")
+ for index, (name, group_id) in enumerate(sorted(self.emojis_model.emoji_groups, key=lambda g: g[0])):
+ self.win.emojiFilterGroup.addItem(name, group_id)
+ if group_id == default_group_id:
dropdown_index = index + 1
-
self.win.emojiFilterGroup.currentIndexChanged.connect(self.group_changed)
self.win.emojiFilterGroup.setCurrentIndex(dropdown_index)
diff --git a/src/windows/views/files_listview.py b/src/windows/views/files_listview.py
index 02a2e4a4a..300908b21 100644
--- a/src/windows/views/files_listview.py
+++ b/src/windows/views/files_listview.py
@@ -28,9 +28,11 @@
import uuid
-from PyQt5.QtCore import QSize, Qt, QPoint, QRegExp
-from PyQt5.QtGui import QDrag, QCursor, QPixmap, QPainter, QIcon
-from PyQt5.QtWidgets import QListView, QAbstractItemView
+from qt_api import QSize, Qt, QPoint
+from qt_api import clear_override_cursor
+from qt_api import modifiers_has
+from qt_api import QDrag, QCursor, QPixmap, QPainter, QIcon
+from qt_api import QListView, QAbstractItemView
from classes import info
from classes.app import get_app
@@ -108,14 +110,14 @@ def contextMenuEvent(self, event):
menu.addSeparator()
# Show menu
- menu.popup(event.globalPos())
+ menu.show_at(event)
def mouseDoubleClickEvent(self, event):
super(FilesListView, self).mouseDoubleClickEvent(event)
# Preview File, File Properties, or Split File (depending on Shift/Ctrl)
- if int(get_app().keyboardModifiers() & Qt.ShiftModifier) > 0:
+ if modifiers_has(get_app().keyboardModifiers(), Qt.ShiftModifier):
get_app().window.actionSplitFile.trigger()
- elif int(get_app().keyboardModifiers() & Qt.ControlModifier) > 0:
+ elif modifiers_has(get_app().keyboardModifiers(), Qt.ControlModifier):
get_app().window.actionFile_Properties.trigger()
else:
get_app().window.actionPreview_File.trigger()
@@ -184,8 +186,12 @@ def startDrag(self, supportedActions):
tid = str(uuid.uuid4())
get_app().updates.transaction_id = tid
- # Execute the drag operation (blocking - dropEvent creates clips during this call)
- drag.exec_(supportedActions)
+ # Execute the drag operation
+ exec_fn = getattr(drag, "exec", None) or getattr(drag, "exec_", None)
+ if exec_fn is None:
+ raise AttributeError("QDrag has no exec_/exec method")
+ exec_fn(supportedActions)
+ clear_override_cursor()
# End transaction
get_app().updates.transaction_id = None
@@ -224,7 +230,10 @@ def refresh_view(self):
"""Filter files with proxy class"""
filter_text = self.win.filesFilter.text()
# Apply filter to the source proxy model (not the single-column wrapper)
- self.files_model.proxy_model.setFilterRegExp(QRegExp(filter_text.replace(' ', '.*'), Qt.CaseInsensitive))
+ from qt_api import make_filter_regex, set_proxy_filter
+ pattern = filter_text.replace(' ', '.*')
+ regex = make_filter_regex(pattern, case_insensitive=True)
+ set_proxy_filter(self.files_model.proxy_model, regex)
col = self.files_model.proxy_model.sortColumn()
self.files_model.proxy_model.sort(col)
diff --git a/src/windows/views/files_treeview.py b/src/windows/views/files_treeview.py
index 07dbddc62..c62179873 100644
--- a/src/windows/views/files_treeview.py
+++ b/src/windows/views/files_treeview.py
@@ -30,9 +30,11 @@
import os
import uuid
-from PyQt5.QtCore import QSize, Qt, QPoint
-from PyQt5.QtGui import QDrag, QCursor, QPixmap, QPainter, QIcon
-from PyQt5.QtWidgets import QTreeView, QAbstractItemView, QSizePolicy, QHeaderView
+from qt_api import QSize, Qt, QPoint
+from qt_api import clear_override_cursor
+from qt_api import modifiers_has
+from qt_api import QDrag, QCursor, QPixmap, QPainter, QIcon
+from qt_api import QTreeView, QAbstractItemView, QSizePolicy, QHeaderView
from classes import info
from classes.app import get_app
@@ -109,16 +111,16 @@ def contextMenuEvent(self, event):
menu.addSeparator()
# Show menu
- menu.popup(event.globalPos())
+ menu.show_at(event)
def mouseDoubleClickEvent(self, event):
# Get the index of the item at the click position
index = self.indexAt(event.pos())
if index.column() == 0:
# If column 0 (thumbnail) is double-clicked, trigger the custom actions
- if int(get_app().keyboardModifiers() & Qt.ShiftModifier) > 0:
+ if modifiers_has(get_app().keyboardModifiers(), Qt.ShiftModifier):
get_app().window.actionSplitFile.trigger()
- elif int(get_app().keyboardModifiers() & Qt.ControlModifier) > 0:
+ elif modifiers_has(get_app().keyboardModifiers(), Qt.ControlModifier):
get_app().window.actionFile_Properties.trigger()
else:
get_app().window.actionPreview_File.trigger()
@@ -187,8 +189,12 @@ def startDrag(self, supportedActions):
tid = str(uuid.uuid4())
get_app().updates.transaction_id = tid
- # Execute the drag operation (blocking - dropEvent creates clips during this call)
- drag.exec_(supportedActions)
+ # Execute the drag operation
+ exec_fn = getattr(drag, "exec", None) or getattr(drag, "exec_", None)
+ if exec_fn is None:
+ raise AttributeError("QDrag has no exec_/exec method")
+ exec_fn(supportedActions)
+ clear_override_cursor()
# End transaction
get_app().updates.transaction_id = None
diff --git a/src/windows/views/find_file.py b/src/windows/views/find_file.py
index 299a07968..273457843 100644
--- a/src/windows/views/find_file.py
+++ b/src/windows/views/find_file.py
@@ -28,7 +28,7 @@
import os
from classes import info
from classes.app import get_app
-from PyQt5.QtWidgets import QMessageBox, QFileDialog
+from qt_api import QMessageBox, QFileDialog
# Keep track of all previously checked paths, and keep checking them
known_paths = [info.HOME_PATH]
diff --git a/src/windows/views/menu.py b/src/windows/views/menu.py
index b5a96a0b7..3145cd87d 100644
--- a/src/windows/views/menu.py
+++ b/src/windows/views/menu.py
@@ -25,9 +25,9 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtWidgets import QMenu
-from PyQt5.QtGui import QPainter, QPen, QColor
-from PyQt5.QtCore import Qt, QRectF
+from qt_api import QMenu
+from qt_api import QPainter, QPen, QColor
+from qt_api import Qt, QRectF
from classes.app import get_app
import re
@@ -39,6 +39,25 @@ def __init__(self, title=None, parent=None):
self.border = self.get_border()
self.border_radius = self.get_border_radius()
+ def show_at(self, event_or_pos):
+ """Show the menu at a position or context menu event."""
+ pos = event_or_pos
+ if hasattr(event_or_pos, "globalPosition"):
+ try:
+ pos = event_or_pos.globalPosition().toPoint()
+ except Exception:
+ pos = event_or_pos
+ if hasattr(event_or_pos, "globalPos"):
+ try:
+ pos = event_or_pos.globalPos()
+ except Exception:
+ pass
+ exec_fn = getattr(self, "exec", None) or getattr(self, "exec_", None)
+ if exec_fn:
+ exec_fn(pos)
+ else:
+ self.popup(pos)
+
def get_border(self):
"""Parses border width and color from app.styleSheet()"""
pattern = r'QMenu\s*{\s*[^}]*border:\s*([^;]+);'
diff --git a/src/windows/views/profiles_treeview.py b/src/windows/views/profiles_treeview.py
index 08a54b7de..bef617dbd 100644
--- a/src/windows/views/profiles_treeview.py
+++ b/src/windows/views/profiles_treeview.py
@@ -25,9 +25,9 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import Qt, QItemSelectionModel, QRegExp, pyqtSignal, QTimer
-from PyQt5.QtGui import QIcon
-from PyQt5.QtWidgets import QListView, QTreeView, QAbstractItemView, QSizePolicy, QAction
+from qt_api import Qt, QItemSelectionModel, QRegularExpression, pyqtSignal, QTimer
+from qt_api import QIcon
+from qt_api import QListView, QTreeView, QAbstractItemView, QSizePolicy, QAction
from classes.app import get_app
from windows.models.profiles_model import ProfilesModel
@@ -59,8 +59,8 @@ def refresh_view(self, filter_text=""):
"""Filter transitions with proxy class"""
self.is_filter_running = True
self.model().setFilterCaseSensitivity(Qt.CaseInsensitive)
- self.model().setFilterRegExp(QRegExp(filter_text.lower()))
- self.model().sort(Qt.DescendingOrder)
+ self.model().setFilterRegularExpression(QRegularExpression(filter_text.lower()))
+ self.model().sort(0, Qt.DescendingOrder)
# Format columns
self.sortByColumn(0, Qt.DescendingOrder)
@@ -126,11 +126,11 @@ def contextMenuEvent(self, event):
delete_action.triggered.connect(lambda: get_app().window.actionProfileEdit_trigger(profile, delete=True, parent=self))
menu.addAction(delete_action)
- menu.popup(event.globalPos())
+ menu.show_at(event)
def __init__(self, dialog, profiles, *args):
# Invoke parent init
- QListView.__init__(self, *args)
+ QTreeView.__init__(self, *args)
# Get a reference to the window object
self.parent = dialog
diff --git a/src/windows/views/properties_tableview.py b/src/windows/views/properties_tableview.py
index dc39df187..7b548ffe2 100644
--- a/src/windows/views/properties_tableview.py
+++ b/src/windows/views/properties_tableview.py
@@ -29,15 +29,15 @@
import json
import functools
from operator import itemgetter
-import sip
import uuid
-from PyQt5.QtCore import Qt, QRectF, QLocale, pyqtSignal, pyqtSlot, QEvent, QPoint
-from PyQt5.QtGui import (
+from qt_api import Qt, QRectF, QLocale, pyqtSignal, pyqtSlot, QEvent, QPoint, QPointF
+from qt_api import isdeleted
+from qt_api import (
QIcon, QColor, QBrush, QPen, QPalette, QPixmap,
QPainter, QPainterPath, QLinearGradient, QFont, QFontInfo, QCursor,
)
-from PyQt5.QtWidgets import (
+from qt_api import (
QTableView, QAbstractItemView, QSizePolicy,
QHeaderView, QItemDelegate, QStyle, QLabel,
QPushButton, QHBoxLayout, QFrame, QFontDialog
@@ -130,7 +130,12 @@ def paint(self, painter, option, index):
painter.setBrush(QColor(red, green, blue))
else:
# Normal Keyframe
- if option.state & QStyle.State_Selected:
+ state_selected = getattr(QStyle, "State_Selected", None)
+ if state_selected is None:
+ state_flag = getattr(QStyle, "StateFlag", None)
+ if state_flag:
+ state_selected = getattr(state_flag, "State_Selected", None)
+ if state_selected and option.state & state_selected:
painter.setBrush(background_color)
else:
painter.setBrush(background_color)
@@ -151,7 +156,7 @@ def paint(self, painter, option, index):
painter.setClipRect(mask_rect, Qt.IntersectClip)
# gradient for value box
- gradient = QLinearGradient(option.rect.topLeft(), option.rect.topRight())
+ gradient = QLinearGradient(QPointF(option.rect.topLeft()), QPointF(option.rect.topRight()))
gradient.setColorAt(0, foreground_color)
gradient.setColorAt(1, foreground_color)
@@ -181,6 +186,12 @@ def paint(self, painter, option, index):
painter.restore()
+def _event_posf(event):
+ if hasattr(event, "position"):
+ return event.position()
+ return QPointF(event.pos())
+
+
class PropertiesTableView(QTableView):
""" A Properties Table QWidget used on the main window """
loadProperties = pyqtSignal(list)
@@ -216,7 +227,7 @@ def _start_edit_on_key(self, event):
# For numeric keys, clobber the existing value with the typed character
if result and is_numeric:
- from PyQt5.QtCore import QTimer
+ from qt_api import QTimer
typed_char = event.text()
def set_initial_value():
editor = self.indexWidget(index)
@@ -379,7 +390,8 @@ def value_updated_wrapper(self, item):
def mousePressEvent(self, event):
self.mouse_pressed = True
- row = self.indexAt(event.pos()).row()
+ pos = _event_posf(event).toPoint()
+ row = self.indexAt(pos).row()
model = self.clip_properties_model.model
if model.item(row, 1):
self.selected_item = model.item(row, 1)
@@ -390,12 +402,14 @@ def mousePressEvent(self, event):
def mouseMoveEvent(self, event):
# Get data model and selection
model = self.clip_properties_model.model
+ posf = _event_posf(event)
# Do not change selected row during mouse move
if self.lock_selection and self.prev_row:
row = self.prev_row
else:
- row = self.indexAt(event.pos()).row()
+ pos = _event_posf(event).toPoint()
+ row = self.indexAt(pos).row()
self.prev_row = row
self.lock_selection = True
@@ -409,8 +423,8 @@ def mouseMoveEvent(self, event):
self.selected_item = model.item(row, 1)
# Verify label has not been deleted
- if (self.selected_label and sip.isdeleted(self.selected_label)) or \
- (self.selected_item and sip.isdeleted(self.selected_item)):
+ if (self.selected_label and isdeleted(self.selected_label)) or \
+ (self.selected_item and isdeleted(self.selected_item)):
log.debug("Property has been deleted, skipping")
self.selected_label = None
self.selected_item = None
@@ -427,7 +441,7 @@ def mouseMoveEvent(self, event):
# Get the position of the cursor and % value
value_column_x = self.columnViewportPosition(1)
- cursor_value = event.x() - value_column_x
+ cursor_value = posf.x() - value_column_x
cursor_value_percent = cursor_value / self.columnWidth(1)
# Get data from selected item
@@ -466,13 +480,13 @@ def mouseMoveEvent(self, event):
if self.previous_x == -1:
# Start tracking movement (init diff_length and previous_x)
self.diff_length = 10
- self.previous_x = event.x()
+ self.previous_x = posf.x()
# Calculate # of pixels dragged
- drag_diff = self.previous_x - event.x()
+ drag_diff = self.previous_x - posf.x()
# update previous x
- self.previous_x = event.x()
+ self.previous_x = posf.x()
# Ignore small initial movements
if abs(drag_diff) < self.diff_length:
@@ -527,7 +541,8 @@ def mouseReleaseEvent(self, event):
# Get data model and selection
model = self.clip_properties_model.model
- row = self.indexAt(event.pos()).row()
+ pos = _event_posf(event).toPoint()
+ row = self.indexAt(pos).row()
if model.item(row, 0):
self.selected_label = model.item(row, 0)
self.selected_item = model.item(row, 1)
@@ -618,8 +633,8 @@ def caption_text_updated(self, new_caption_text, caption_model_row):
caption_model_value = caption_model_row[1]
# Verify label has not been deleted
- if (caption_model_label and sip.isdeleted(caption_model_label)) or \
- (caption_model_value and sip.isdeleted(caption_model_value)):
+ if (caption_model_label and isdeleted(caption_model_label)) or \
+ (caption_model_value and isdeleted(caption_model_value)):
log.debug("Property has been deleted, skipping")
return
@@ -660,13 +675,14 @@ def filter_changed(self, value=None):
def contextMenuEvent(self, event):
""" Display context menu """
# Get property being acted on
- index = self.indexAt(event.pos())
+ pos = _event_posf(event).toPoint()
+ index = self.indexAt(pos)
if not index.isValid():
event.ignore()
return
# Get data model and selection
- idx = self.indexAt(event.pos())
+ idx = self.indexAt(pos)
row = idx.row()
selected_label = idx.model().item(row, 0)
selected_value = idx.model().item(row, 1)
@@ -1045,7 +1061,7 @@ def _gather(dir_path):
# Show context menu (if any options present)
# There is always at least 1 QAction in an empty menu though
if len(self.menu.children()) > 1:
- self.menu.popup(event.globalPos())
+ self.menu.show_at(event)
# Focus the first menu item for keyboard navigation
actions = self.menu.actions()
if actions:
@@ -1142,8 +1158,8 @@ def Insert_Action_Triggered(self):
log.info("Insert_Action_Triggered")
# Verify label has not been deleted
- if (self.selected_label and sip.isdeleted(self.selected_label)) or \
- (self.selected_item and sip.isdeleted(self.selected_item)):
+ if (self.selected_label and isdeleted(self.selected_label)) or \
+ (self.selected_item and isdeleted(self.selected_item)):
log.debug("Property has been deleted, skipping")
self.selected_label = None
self.selected_item = None
@@ -1452,21 +1468,27 @@ def select_item(self, selection):
self.btnSelectionName.setIcon(QIcon())
self.btnSelectionName.setMenu(None)
return
+ def _set_item_icon(path):
+ if path and isinstance(path, (str, bytes, os.PathLike)) and os.path.exists(path):
+ self.item_icon = QIcon(QPixmap(path))
+ else:
+ self.item_icon = QIcon()
+
if self.item_type == "clip":
clip = Clip.get(id=self.item_id)
if clip:
self.item_name = clip.title()
- self.item_icon = QIcon(QPixmap(clip.data.get('image')))
+ _set_item_icon(clip.data.get('image'))
elif self.item_type == "transition":
trans = Transition.get(id=self.item_id)
if trans:
self.item_name = _(trans.title())
- self.item_icon = QIcon(QPixmap(trans.data.get('reader', {}).get('path')))
+ _set_item_icon(trans.data.get('reader', {}).get('path'))
elif self.item_type == "effect":
effect = Effect.get(id=self.item_id)
if effect:
self.item_name = _(effect.title())
- self.item_icon = QIcon(QPixmap(os.path.join(info.PATH, "effects", "icons", "%s.png" % effect.data.get('class_name').lower())))
+ _set_item_icon(os.path.join(info.PATH, "effects", "icons", "%s.png" % effect.data.get('class_name').lower()))
# Truncate long text
if self.item_name and len(self.item_name) > 25:
diff --git a/src/windows/views/repeat.py b/src/windows/views/repeat.py
index 2d1d7b5c0..e20994ec2 100644
--- a/src/windows/views/repeat.py
+++ b/src/windows/views/repeat.py
@@ -27,7 +27,7 @@
import copy
import openshot
-from PyQt5.QtWidgets import (
+from qt_api import (
QDialog, QFormLayout, QComboBox, QSpinBox, QDoubleSpinBox,
QDialogButtonBox, QHBoxLayout
)
diff --git a/src/windows/views/timeline.py b/src/windows/views/timeline.py
index 840f02942..54f714017 100644
--- a/src/windows/views/timeline.py
+++ b/src/windows/views/timeline.py
@@ -38,9 +38,10 @@
from random import uniform
import openshot
-from PyQt5.QtCore import pyqtSlot, Qt, QCoreApplication, QTimer, pyqtSignal, QPointF
-from PyQt5.QtGui import QCursor, QKeySequence
-from PyQt5.QtWidgets import QDialog
+from qt_api import pyqtSlot, Qt, QCoreApplication, QTimer, pyqtSignal, QPointF
+from qt_api import modifiers_has
+from qt_api import QCursor, QKeySequence
+from qt_api import QDialog
from classes import info, updates
from classes.app import get_app
@@ -84,7 +85,17 @@
log.error("Import failure loading WebKit backend", exc_info=1)
finally:
if not ViewClass:
- raise RuntimeError("Need PyQt5.QtWebEngine (or PyQt5.QtWebView on Win32)") from ex
+ raise RuntimeError(
+ "Need QtWebEngine for the active Qt binding (PyQt6/PyQt5 or PySide6)."
+ ) from ex
+
+log.info("Timeline backend: %s (%s)", info.WEB_BACKEND, getattr(ViewClass, "__name__", "unknown"))
+
+
+def _event_posf(event):
+ if hasattr(event, "posF"):
+ return event.posF()
+ return event.position()
class TimelineView(updates.UpdateInterface, ViewClass):
@@ -319,11 +330,12 @@ def _qwidget_paste_coordinates(self, local_pos, clip_ids, tran_ids):
if hasattr(self, "_seconds_from_x"):
seconds = max(0.0, float(self._seconds_from_x(local_pos.x())))
+ local_posf = QPointF(local_pos)
track_number = None
if hasattr(self, "geometry"):
self.geometry.ensure()
for track_rect, track, _name_rect in getattr(self.geometry, "track_rects", []):
- if track_rect.contains(local_pos):
+ if track_rect.contains(local_posf):
track_number = track.data.get("number")
break
@@ -978,7 +990,7 @@ def ShowPlayheadMenu(self, position=None):
# Show context menu
self.context_menu_cursor_position = QCursor.pos()
- return menu.popup(self.context_menu_cursor_position)
+ return menu.show_at(self.context_menu_cursor_position)
@pyqtSlot(str)
def ShowEffectMenu(self, effect_id=None):
@@ -1005,7 +1017,7 @@ def ShowEffectMenu(self, effect_id=None):
# Show context menu
self.context_menu_cursor_position = QCursor.pos()
- return menu.popup(self.context_menu_cursor_position)
+ return menu.show_at(self.context_menu_cursor_position)
@pyqtSlot(float, int)
def ShowTimelineMenu(self, position, layer_number):
@@ -1091,7 +1103,7 @@ def ShowTimelineMenu(self, position, layer_number):
# Show context menu
self.context_menu_cursor_position = QCursor.pos()
- return menu.popup(self.context_menu_cursor_position)
+ return menu.show_at(self.context_menu_cursor_position)
@pyqtSlot(str)
def ShowClipMenu(self, clip_id=None):
@@ -1592,7 +1604,7 @@ def ShowClipMenu(self, clip_id=None):
# Show context menu
self.context_menu_cursor_position = QCursor.pos()
- return menu.popup(self.context_menu_cursor_position)
+ return menu.show_at(self.context_menu_cursor_position)
def Transform_Triggered(self, action, clip_ids):
log.debug("Transform_Triggered")
@@ -2639,9 +2651,9 @@ def RazorSliceAtCursor(self, clip_id, trans_id, cursor_position):
# Determine slice mode (keep both [default], keep left [shift], keep right [ctrl]
slice_mode = MenuSlice.KEEP_BOTH
- if int(QCoreApplication.instance().keyboardModifiers() & Qt.ControlModifier) > 0:
+ if modifiers_has(QCoreApplication.instance().keyboardModifiers(), Qt.ControlModifier):
slice_mode = MenuSlice.KEEP_RIGHT
- elif int(QCoreApplication.instance().keyboardModifiers() & Qt.ShiftModifier) > 0:
+ elif modifiers_has(QCoreApplication.instance().keyboardModifiers(), Qt.ShiftModifier):
slice_mode = MenuSlice.KEEP_LEFT
if clip_id:
@@ -3493,7 +3505,7 @@ def ShowTransitionMenu(self, tran_id=None):
# Show context menu
self.context_menu_cursor_position = QCursor.pos()
- return menu.popup(self.context_menu_cursor_position)
+ return menu.show_at(self.context_menu_cursor_position)
@pyqtSlot(str)
def ShowTrackMenu(self, layer_id=None):
@@ -3568,7 +3580,7 @@ def ShowTrackMenu(self, layer_id=None):
# Show context menu
self.context_menu_cursor_position = QCursor.pos()
- return menu.popup(self.context_menu_cursor_position)
+ return menu.show_at(self.context_menu_cursor_position)
@pyqtSlot(str)
def ShowMarkerMenu(self, marker_id=None):
@@ -3582,7 +3594,7 @@ def ShowMarkerMenu(self, marker_id=None):
# Show context menu
self.context_menu_cursor_position = QCursor.pos()
- return menu.popup(self.context_menu_cursor_position)
+ return menu.show_at(self.context_menu_cursor_position)
@pyqtSlot()
def EnableCacheThread(self):
@@ -3899,6 +3911,88 @@ def update_zoom(self, newScale):
get_app().updates.update(["scale"], newScale)
get_app().updates.ignore_history = False
+ def _mime_text_payload(self, mime):
+ """Return text payload from a mime object, if available."""
+ try:
+ text = mime.text()
+ except Exception:
+ text = ""
+ if text:
+ return text
+ for fmt in ("text/plain", "application/json"):
+ try:
+ raw = mime.data(fmt)
+ except Exception:
+ raw = None
+ if not raw:
+ continue
+ try:
+ return bytes(raw).decode("utf-8", "ignore")
+ except Exception:
+ try:
+ return raw.data().decode("utf-8", "ignore")
+ except Exception:
+ continue
+ return ""
+
+ def _mime_json_list(self, mime):
+ """Parse a JSON list from mime text, if present."""
+ payload = self._mime_text_payload(mime)
+ if not payload:
+ return []
+ try:
+ data_list = json.loads(payload)
+ except Exception:
+ return []
+ if not isinstance(data_list, list):
+ data_list = [data_list]
+ return data_list
+
+ def _parse_js_position_result(self, result):
+ """Normalize JS position results into a dict."""
+ if isinstance(result, dict):
+ return result
+ if result is None:
+ return None
+ if isinstance(result, (bytes, bytearray)):
+ try:
+ result = result.decode("utf-8", "ignore")
+ except Exception:
+ return None
+ if isinstance(result, str):
+ if not result:
+ return None
+ try:
+ parsed = json.loads(result)
+ except Exception:
+ return None
+ return parsed if isinstance(parsed, dict) else None
+ return None
+
+ def _run_js_position(self, x, y, callback):
+ """Run getJavaScriptPosition and normalize its result."""
+ code = (
+ "(function(){"
+ "try{var r="
+ + JS_SCOPE_SELECTOR
+ + ".getJavaScriptPosition("
+ + str(x)
+ + ","
+ + str(y)
+ + ");return JSON.stringify(r);}catch(e){return JSON.stringify({error:String(e)});}"
+ "})()"
+ )
+
+ def _wrapped(result):
+ parsed = self._parse_js_position_result(result)
+ if parsed is None:
+ log.warning("Timeline js_position: empty result (%s)", result)
+ elif parsed.get("error"):
+ log.warning("Timeline js_position error: %s", parsed.get("error"))
+ callback(parsed)
+
+ self.run_js(code, _wrapped)
+
# An item is being dragged onto the timeline (mouse is entering the timeline now)
def dragEnterEvent(self, event):
if ViewClass == TimelineWidget:
@@ -3913,14 +4007,18 @@ def dragEnterEvent(self, event):
# Initialize a list to hold file data (either from mime data or newly created files)
data_list = []
- initial_pos = event.posF()
+ initial_pos = _event_posf(event)
# Get FPS and scaling information
fps_float = float(get_app().project.get("fps")["num"]) / float(get_app().project.get("fps")["den"])
snap_to_grid = lambda t: round(t * fps_float) / fps_float
+ # Handle text-based mime data (clips or transitions)
+ if event.mimeData().html():
+ self.item_type = event.mimeData().html()
+ data_list = self._mime_json_list(event.mimeData())
# Handle URL-based OS file drop
- if event.mimeData().hasUrls():
+ elif event.mimeData().hasUrls():
self.item_type = "clip"
urls = event.mimeData().urls()
@@ -3938,14 +4036,6 @@ def dragEnterEvent(self, event):
if file:
data_list.append(file.id)
- # Handle text-based mime data (clips or transitions)
- elif event.mimeData().html():
- self.item_type = event.mimeData().html()
- data_list = json.loads(event.mimeData().text())
-
- if not isinstance(data_list, list):
- data_list = [data_list]
-
# If no valid item type, return
if not self.item_type:
return
@@ -3962,8 +4052,16 @@ def handle_js_position(pos, js_position_data):
tid = self.get_uuid()
get_app().updates.transaction_id = tid
+ if not js_position_data:
+ log.warning("Timeline dragEnter js_position: empty result")
+ return
js_position = snap_to_grid(js_position_data.get('position', 0.0))
js_nearest_track = js_position_data.get('track', 0)
+ if not js_nearest_track:
+ try:
+ js_nearest_track = int(self.timeline_sync.timeline.GetTrackCount())
+ except Exception:
+ js_nearest_track = 0
pos.setX(js_position)
@@ -3989,8 +4087,13 @@ def handle_js_position(pos, js_position_data):
self.run_js(JS_SCOPE_SELECTOR + ".startManualMove('{}', '{}');".format(self.item_type, json.dumps(self.item_ids)))
# Get JS position and pass initial position to the callback
- self.run_js(JS_SCOPE_SELECTOR + ".getJavaScriptPosition({}, {});"
- .format(initial_pos.x(), initial_pos.y()), partial(handle_js_position, initial_pos))
+ def _deferred_js_position():
+ self._run_js_position(
+ initial_pos.x(),
+ initial_pos.y(),
+ partial(handle_js_position, initial_pos),
+ )
+ QTimer.singleShot(0, _deferred_js_position)
# Accept the event
event.accept()
@@ -4000,6 +4103,7 @@ def addClip(self, file_id, position, track, ignore_refresh=False, call_manual_mo
# Retrieve File object by file_id
file = File.get(id=file_id)
if not file:
+ log.warning("addClip: file_id not found: %s", file_id)
return # Skip if the file is not found
# Get file name and path
@@ -4327,7 +4431,7 @@ def dragMoveEvent(self, event):
event.accept()
# Get cursor position
- pos = event.posF()
+ pos = _event_posf(event)
# Move clip on timeline
if self.item_type in ["clip", "transition"]:
@@ -4344,9 +4448,76 @@ def dropEvent(self, event):
# Accept the event
event.accept()
+ def cleanup_drop():
+ self.new_item = False
+ self.item_type = None
+ self.item_ids = []
+ get_app().updates.transaction_id = None
+
+ # If drag enter didn't build item_ids, fall back to parsing drop mime data.
+ if self.item_type in ["clip", "transition"] and not self.item_ids:
+ mime = event.mimeData()
+ data_list = []
+ data_list = self._mime_json_list(mime)
+ if not data_list and mime.hasUrls():
+ urls = mime.urls()
+ get_app().window.files_model.process_urls(urls, import_quietly=True, prevent_image_seq=True)
+ for uri in urls:
+ filepath = uri.toLocalFile()
+ if not os.path.exists(filepath) or not os.path.isfile(filepath):
+ continue
+ for file in File.filter(path=filepath):
+ if file:
+ data_list.append(file.id)
+ if data_list:
+ pos = _event_posf(event)
+
+ def handle_js_position(pos, js_position_data):
+ tid = self.get_uuid()
+ get_app().updates.transaction_id = tid
+
+ fps_float = float(get_app().project.get("fps")["num"]) / float(get_app().project.get("fps")["den"])
+ snap_to_grid = lambda t: round(t * fps_float) / fps_float
+ if not js_position_data:
+ log.warning("Timeline drop fallback js_position: empty result")
+ cleanup_drop()
+ return
+ js_position = snap_to_grid(js_position_data.get('position', 0.0))
+ js_nearest_track = js_position_data.get('track', 0)
+ if not js_nearest_track:
+ try:
+ js_nearest_track = int(self.timeline_sync.timeline.GetTrackCount())
+ except Exception:
+ js_nearest_track = 0
+ pos.setX(js_position)
+
+ self.item_ids = []
+ for index, drag_id in enumerate(data_list):
+ ignore_refresh = False if index == len(data_list) - 1 else True
+ if self.item_type == "clip":
+ self.addClip(drag_id, pos, js_nearest_track, ignore_refresh, call_manual_move=False)
+ elif self.item_type == "transition":
+ self.addTransition(drag_id, pos, js_nearest_track, ignore_refresh, call_manual_move=False)
+
+ if self.item_ids:
+ self.run_js(
+ JS_SCOPE_SELECTOR + ".updateRecentItemJSON('{}', '{}', '{}');"
+ .format(self.item_type, json.dumps(self.item_ids), get_app().updates.transaction_id)
+ )
+ cleanup_drop()
+
+ def _deferred_drop_position():
+ self._run_js_position(
+ pos.x(),
+ pos.y(),
+ partial(handle_js_position, pos),
+ )
+ QTimer.singleShot(0, _deferred_drop_position)
+ return
+
if self.item_type == "effect":
- pos = event.posF()
- data = json.loads(event.mimeData().text())
+ pos = _event_posf(event)
+ data = self._mime_json_list(event.mimeData())
self.addEffect(data, pos)
elif self.item_type in ["clip", "transition"] and self.item_ids:
@@ -4355,10 +4526,7 @@ def dropEvent(self, event):
.format(self.item_type, json.dumps(self.item_ids), get_app().updates.transaction_id))
# Cleanup after drop
- self.new_item = False
- self.item_type = None
- self.item_ids = []
- get_app().updates.transaction_id = None
+ cleanup_drop()
def dragLeaveEvent(self, event):
"""A drag is in-progress and the user moves mouse outside of timeline"""
@@ -4425,6 +4593,8 @@ def render_cache_json(self):
# Get the JSON from the cache object (i.e. which frames are cached)
cache_json = cache_object.Json()
cache_dict = json.loads(cache_json)
+ if not isinstance(cache_dict, dict):
+ return
cache_version = cache_dict["version"]
if self.cache_renderer_version == cache_version:
@@ -4445,9 +4615,10 @@ def handle_selection(self):
self.run_js(JS_SCOPE_SELECTOR + ".refreshTimeline();")
def __init__(self, window):
- super().__init__()
if ViewClass == TimelineWidget:
TimelineWidget.__init__(self)
+ else:
+ super().__init__()
self.setObjectName("TimelineView")
app = get_app()
diff --git a/src/windows/views/timeline_backend/colors.py b/src/windows/views/timeline_backend/colors.py
index 53c024e9c..77a47f2c7 100644
--- a/src/windows/views/timeline_backend/colors.py
+++ b/src/windows/views/timeline_backend/colors.py
@@ -27,7 +27,7 @@
from typing import Any, Dict
-from PyQt5.QtGui import QColor
+from qt_api import QColor
# Mapping of known effect names to their representative colors. These values
# mirror the existing web timeline so both backends stay consistent.
diff --git a/src/windows/views/timeline_backend/geometry/base.py b/src/windows/views/timeline_backend/geometry/base.py
index 02676ca14..c55106fe9 100644
--- a/src/windows/views/timeline_backend/geometry/base.py
+++ b/src/windows/views/timeline_backend/geometry/base.py
@@ -27,7 +27,7 @@
from bisect import bisect_left
-from PyQt5.QtCore import QPointF, QRectF
+from qt_api import QPointF, QRectF
from classes.app import get_app
from classes.logger import log
diff --git a/src/windows/views/timeline_backend/geometry/clip.py b/src/windows/views/timeline_backend/geometry/clip.py
index f9b37eec4..e8a2b5a12 100644
--- a/src/windows/views/timeline_backend/geometry/clip.py
+++ b/src/windows/views/timeline_backend/geometry/clip.py
@@ -25,7 +25,7 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QRectF
+from qt_api import QRectF
from classes.query import Clip
diff --git a/src/windows/views/timeline_backend/geometry/marker.py b/src/windows/views/timeline_backend/geometry/marker.py
index b2a256e17..52f0234ac 100644
--- a/src/windows/views/timeline_backend/geometry/marker.py
+++ b/src/windows/views/timeline_backend/geometry/marker.py
@@ -25,7 +25,7 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QRectF
+from qt_api import QRectF
from classes.query import Marker
diff --git a/src/windows/views/timeline_backend/geometry/track.py b/src/windows/views/timeline_backend/geometry/track.py
index 28bb57e3d..a9b549004 100644
--- a/src/windows/views/timeline_backend/geometry/track.py
+++ b/src/windows/views/timeline_backend/geometry/track.py
@@ -25,7 +25,7 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QRectF
+from qt_api import QRectF
from classes.query import Track
diff --git a/src/windows/views/timeline_backend/geometry/transition.py b/src/windows/views/timeline_backend/geometry/transition.py
index 3a899316e..a2f7f081d 100644
--- a/src/windows/views/timeline_backend/geometry/transition.py
+++ b/src/windows/views/timeline_backend/geometry/transition.py
@@ -25,7 +25,7 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QRectF
+from qt_api import QRectF
from classes.query import Transition
diff --git a/src/windows/views/timeline_backend/paint/background.py b/src/windows/views/timeline_backend/paint/background.py
index c6225fffd..b786e80f5 100644
--- a/src/windows/views/timeline_backend/paint/background.py
+++ b/src/windows/views/timeline_backend/paint/background.py
@@ -25,8 +25,8 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QRectF
-from PyQt5.QtGui import QBrush, QColor, QLinearGradient, QPainter
+from qt_api import QRectF
+from qt_api import QBrush, QColor, QLinearGradient, QPainter
from .base import BasePainter
diff --git a/src/windows/views/timeline_backend/paint/base.py b/src/windows/views/timeline_backend/paint/base.py
index 10f238da3..9b53384d4 100644
--- a/src/windows/views/timeline_backend/paint/base.py
+++ b/src/windows/views/timeline_backend/paint/base.py
@@ -25,10 +25,9 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QRectF, Qt
-from PyQt5.QtGui import QColor, QImage, QPainter, QPen, QPixmap
-from PyQt5.QtSvg import QSvgRenderer
import math
+from qt_api import QRectF, Qt, QSvgRenderer, QPen
+from qt_api import QImage, QPainter, QPixmap, QColor, QSvgRenderer
import os
diff --git a/src/windows/views/timeline_backend/paint/cache.py b/src/windows/views/timeline_backend/paint/cache.py
index 4bad7414d..92ee4b312 100644
--- a/src/windows/views/timeline_backend/paint/cache.py
+++ b/src/windows/views/timeline_backend/paint/cache.py
@@ -25,8 +25,8 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QRectF, Qt
-from PyQt5.QtGui import QColor, QPainter
+from qt_api import QRectF, Qt
+from qt_api import QColor, QPainter
from .base import BasePainter
diff --git a/src/windows/views/timeline_backend/paint/clip.py b/src/windows/views/timeline_backend/paint/clip.py
index 41ca931e6..3a033519a 100644
--- a/src/windows/views/timeline_backend/paint/clip.py
+++ b/src/windows/views/timeline_backend/paint/clip.py
@@ -25,8 +25,8 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QPointF, QRectF, Qt, QTimer
-from PyQt5.QtGui import (
+from qt_api import QPointF, QRectF, Qt, QTimer
+from qt_api import (
QBrush,
QColor,
QFont,
@@ -39,7 +39,7 @@
QPixmap,
QRadialGradient,
)
-from PyQt5.QtWidgets import QGraphicsBlurEffect, QGraphicsPixmapItem, QGraphicsScene
+from qt_api import QGraphicsBlurEffect, QGraphicsPixmapItem, QGraphicsScene
import math
import os
import time
@@ -1552,7 +1552,7 @@ def handle_thumbnail_ready(self, clip_id, frame, thumb_path, generation):
self._invalidate_clip_cache_for_clip(clip_key)
# Safe repaint — defer to avoid active painter issues
- from PyQt5.QtCore import QTimer
+ from qt_api import QTimer
QTimer.singleShot(0, self.w.update)
def _invalidate_clip_cache_for_clip(self, clip_token):
diff --git a/src/windows/views/timeline_backend/paint/keyframe.py b/src/windows/views/timeline_backend/paint/keyframe.py
index 05e20d551..6d94c8249 100644
--- a/src/windows/views/timeline_backend/paint/keyframe.py
+++ b/src/windows/views/timeline_backend/paint/keyframe.py
@@ -25,8 +25,8 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QRectF
-from PyQt5.QtGui import QColor, QPainter, QPainterPath, QPen
+from qt_api import QRectF
+from qt_api import QColor, QPainter, QPainterPath, QPen
from .base import BasePainter
diff --git a/src/windows/views/timeline_backend/paint/keyframepanel.py b/src/windows/views/timeline_backend/paint/keyframepanel.py
index d4f523b68..f9205631b 100644
--- a/src/windows/views/timeline_backend/paint/keyframepanel.py
+++ b/src/windows/views/timeline_backend/paint/keyframepanel.py
@@ -27,8 +27,8 @@
import math
-from PyQt5.QtCore import QPointF, QRectF, Qt
-from PyQt5.QtGui import QBrush, QColor, QPainter, QPainterPath, QPen
+from qt_api import QPointF, QRectF, Qt
+from qt_api import QBrush, QColor, QPainter, QPainterPath, QPen
from classes.app import get_app
from classes.logger import log
diff --git a/src/windows/views/timeline_backend/paint/marker.py b/src/windows/views/timeline_backend/paint/marker.py
index e5e09a04b..b45f6fd00 100644
--- a/src/windows/views/timeline_backend/paint/marker.py
+++ b/src/windows/views/timeline_backend/paint/marker.py
@@ -25,8 +25,8 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QRectF
-from PyQt5.QtGui import QPainter
+from qt_api import QRectF
+from qt_api import QPainter
from .base import BasePainter
diff --git a/src/windows/views/timeline_backend/paint/playhead.py b/src/windows/views/timeline_backend/paint/playhead.py
index 9a36815df..d50312b26 100644
--- a/src/windows/views/timeline_backend/paint/playhead.py
+++ b/src/windows/views/timeline_backend/paint/playhead.py
@@ -25,8 +25,8 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QPointF, QRectF, Qt
-from PyQt5.QtGui import QBrush, QColor, QPainter, QPen
+from qt_api import QPointF, QRectF, Qt
+from qt_api import QBrush, QColor, QPainter, QPen
import math
from .base import BasePainter
diff --git a/src/windows/views/timeline_backend/paint/ruler.py b/src/windows/views/timeline_backend/paint/ruler.py
index 1af5ba26f..b140751f5 100644
--- a/src/windows/views/timeline_backend/paint/ruler.py
+++ b/src/windows/views/timeline_backend/paint/ruler.py
@@ -25,8 +25,8 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QPointF, QRectF, Qt
-from PyQt5.QtGui import (
+from qt_api import QPointF, QRectF, Qt
+from qt_api import (
QBrush,
QFont,
QFontMetrics,
@@ -42,6 +42,12 @@
from .base import BasePainter
+def _text_width(metrics, text):
+ if hasattr(metrics, "horizontalAdvance"):
+ return metrics.horizontalAdvance(text)
+ return metrics.width(text)
+
+
class RulerPainter(BasePainter):
def update_theme(self):
self.bg = self.w.theme.ruler.background
@@ -185,7 +191,7 @@ def paint(self, painter: QPainter):
tt = secondsToTime(t, fps_info["num"], fps_info["den"])
if frame == 0:
lbl = f"{int(tt['min'])}:{tt['sec']}"
- text_w = tick_metrics.width(lbl)
+ text_w = _text_width(tick_metrics, lbl)
text_rect = QRectF(
x + 2,
label_top,
@@ -197,7 +203,7 @@ def paint(self, painter: QPainter):
lbl = f"{tt['hour']}:{tt['min']}:{tt['sec']}"
if fpt < round(fps_float):
lbl += f",{tt['frame']}"
- text_w = tick_metrics.width(lbl)
+ text_w = _text_width(tick_metrics, lbl)
text_rect = QRectF(
x - text_w / 2,
label_top,
diff --git a/src/windows/views/timeline_backend/paint/scrollbar.py b/src/windows/views/timeline_backend/paint/scrollbar.py
index 156bfd637..cd7d3e6a8 100644
--- a/src/windows/views/timeline_backend/paint/scrollbar.py
+++ b/src/windows/views/timeline_backend/paint/scrollbar.py
@@ -25,8 +25,8 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QRectF
-from PyQt5.QtGui import QBrush, QColor, QPainter
+from qt_api import QRectF
+from qt_api import QBrush, QColor, QPainter
from .base import BasePainter
diff --git a/src/windows/views/timeline_backend/paint/selection.py b/src/windows/views/timeline_backend/paint/selection.py
index 473945e71..83feaa813 100644
--- a/src/windows/views/timeline_backend/paint/selection.py
+++ b/src/windows/views/timeline_backend/paint/selection.py
@@ -25,8 +25,8 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QRectF, Qt
-from PyQt5.QtGui import QPainter, QPen
+from qt_api import QRectF, Qt
+from qt_api import QPainter, QPen
from .base import BasePainter
diff --git a/src/windows/views/timeline_backend/paint/track.py b/src/windows/views/timeline_backend/paint/track.py
index 0da1c88c9..6f79d79fc 100644
--- a/src/windows/views/timeline_backend/paint/track.py
+++ b/src/windows/views/timeline_backend/paint/track.py
@@ -25,10 +25,10 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QPointF, QRectF, Qt
+from qt_api import QPointF, QRectF, Qt
import math
-from PyQt5.QtGui import (
+from qt_api import (
QBrush,
QColor,
QLinearGradient,
diff --git a/src/windows/views/timeline_backend/paint/transition.py b/src/windows/views/timeline_backend/paint/transition.py
index ba7e4c8f2..a66dce99e 100644
--- a/src/windows/views/timeline_backend/paint/transition.py
+++ b/src/windows/views/timeline_backend/paint/transition.py
@@ -25,8 +25,8 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QPointF, QRectF, Qt
-from PyQt5.QtGui import (
+from qt_api import QPointF, QRectF, Qt
+from qt_api import (
QBrush,
QImage,
QLinearGradient,
diff --git a/src/windows/views/timeline_backend/qwidget/base.py b/src/windows/views/timeline_backend/qwidget/base.py
index 8eade6dae..89dcbe115 100644
--- a/src/windows/views/timeline_backend/qwidget/base.py
+++ b/src/windows/views/timeline_backend/qwidget/base.py
@@ -29,26 +29,17 @@
from functools import partial
import openshot
-from PyQt5.QtCore import (
- Qt,
- QRectF,
- QSize,
- QTimer,
- QPointF,
- QSignalTransition,
- QByteArray,
- pyqtSignal,
- pyqtSlot,
- QObject,
- QMetaMethod,
-)
-from PyQt5.QtGui import (
+from qt_api import Qt, QRectF, QSize, QTimer, QPointF, QByteArray, pyqtSignal, QObject, QMetaMethod
+from qt_api import QT_API
+from qt_api import QtCore
+from qt_api import QtCore, QSignalTransition, pyqtSlot, QObject, QMetaMethod
+from qt_api import (
QPainter,
QCursor,
QIcon,
QColor,
)
-from PyQt5.QtWidgets import QSizePolicy, QWidget
+from qt_api import QSizePolicy, QWidget
from ..geometry import Geometry
from ..paint import (
@@ -106,7 +97,13 @@ def _collect_signal_signatures(qobject_type):
_TIMELINE_EVENT_SIGNATURES = _collect_signal_signatures(TimelineEvents)
-class _ConditionalTransition(QSignalTransition):
+def _event_posf(event):
+ if hasattr(event, "position"):
+ return event.position()
+ return QPointF(event.pos())
+
+
+class _ConditionalTransition(QtCore.QSignalTransition):
def __init__(self, sender, signal_bytes, source_state, target_state, condition):
"""Create a QSignalTransition that evaluates a condition before firing."""
@@ -326,9 +323,18 @@ def __init__(self, parent=None):
# Load icon (using display DPI)
self.cursors = {}
+ cursor_fallbacks = {
+ "move": Qt.SizeAllCursor,
+ "resize_x": Qt.SizeHorCursor,
+ "hand": Qt.OpenHandCursor,
+ }
for cursor_name in ["move", "resize_x", "hand"]:
icon = QIcon(":/cursors/cursor_%s.png" % cursor_name)
- self.cursors[cursor_name] = QCursor(icon.pixmap(24, 24))
+ pixmap = icon.pixmap(24, 24)
+ if pixmap.isNull() or pixmap.size().isEmpty():
+ self.cursors[cursor_name] = QCursor(cursor_fallbacks[cursor_name])
+ else:
+ self.cursors[cursor_name] = QCursor(pixmap)
# Init Qt widget's properties (background repainting, etc...)
super().setAttribute(Qt.WA_OpaquePaintEvent)
@@ -509,7 +515,7 @@ def _event_signal(self, name):
return self.events, self._event_signal_bytes(name)
def _add_simple_transition(self, source_state, sender, sig_bytes, target_state):
- t = QSignalTransition(source_state)
+ t = QtCore.QSignalTransition(source_state)
normalized = _normalize_signal_bytes(sig_bytes)
t.setSenderObject(sender)
t.setSignal(QByteArray(normalized))
@@ -916,15 +922,8 @@ def _to_float(value):
def dragEnterEvent(self, event):
self._drag_payload = None
mime = event.mimeData()
-
- if mime.hasUrls():
- event.accept()
- self.new_item = True
- self.item_type = "os_drop"
- self._drag_payload = {"type": "os_drop", "urls": mime.urls()}
- return
-
mime_html = mime.html()
+
if mime_html:
if mime_html in ("clip", "transition"):
try:
@@ -937,12 +936,20 @@ def dragEnterEvent(self, event):
self.item_type = mime_html
self.new_item = True
event.accept()
+ return
elif mime_html == "effect":
+ self._drag_payload = {"type": "effect"}
event.accept()
- else:
- event.ignore()
- else:
- event.ignore()
+ return
+
+ if mime.hasUrls():
+ event.accept()
+ self.new_item = True
+ self.item_type = "os_drop"
+ self._drag_payload = {"type": "os_drop", "urls": mime.urls()}
+ return
+
+ event.ignore()
def dragMoveEvent(self, event):
event.accept()
@@ -975,13 +982,7 @@ def dropEvent(self, event):
effect_names = []
mime = event.mimeData()
mime_html = mime.html()
- if mime.hasUrls():
- urls = mime.urls()
- self.win.files_model.process_urls(urls, import_quietly=True, prevent_image_seq=True)
- for uri in urls:
- for f in File.filter(path=uri.toLocalFile()):
- file_ids.append(f.id)
- elif mime_html == "clip":
+ if mime_html == "clip":
try:
ids = json.loads(mime.text())
except Exception:
@@ -1005,6 +1006,12 @@ def dropEvent(self, event):
if not isinstance(names, list):
names = [names]
effect_names.extend(names)
+ elif mime.hasUrls():
+ urls = mime.urls()
+ self.win.files_model.process_urls(urls, import_quietly=True, prevent_image_seq=True)
+ for uri in urls:
+ for f in File.filter(path=uri.toLocalFile()):
+ file_ids.append(f.id)
if not file_ids and not effect_names:
self._reset_drag_preview()
@@ -1057,9 +1064,6 @@ def _ensure_drag_payload_from_event(self, event):
if self._drag_payload:
return self._drag_payload
mime = event.mimeData()
- if mime.hasUrls():
- self._drag_payload = {"type": "os_drop", "urls": mime.urls()}
- return self._drag_payload
mime_html = mime.html()
if mime_html in {"clip", "transition"}:
try:
@@ -1073,6 +1077,8 @@ def _ensure_drag_payload_from_event(self, event):
self.new_item = True
elif mime_html == "effect":
self._drag_payload = {"type": "effect"}
+ elif mime.hasUrls():
+ self._drag_payload = {"type": "os_drop", "urls": mime.urls()}
return self._drag_payload
def _viewport_offsets(self):
@@ -1094,10 +1100,11 @@ def _viewport_offsets(self):
return h_offset, v_offset
def _event_seconds_track(self, event):
- pos = event.pos()
+ pos = _event_posf(event)
if pos.y() < self.ruler_height:
return None
- if not self.rect().contains(pos):
+ contains_pos = pos.toPoint() if hasattr(pos, "toPoint") else pos
+ if not self.rect().contains(contains_pos):
return None
if not self.track_list:
return None
@@ -2146,30 +2153,31 @@ def _updateCursor(self, pos):
def mousePressEvent(self, event):
self._press_marker = None
+ posf = _event_posf(event)
+ pos = posf
if event.button() == Qt.RightButton:
self._last_event = event
- if self._panel_show_property_menu_at(event.pos()):
+ if self._panel_show_property_menu_at(posf):
event.accept()
return
- icon_entry = self._effect_icon_at(event.pos())
+ icon_entry = self._effect_icon_at(posf)
if icon_entry and self._trigger_effect_context_menu(
icon_entry, event.modifiers() if hasattr(event, "modifiers") else None
):
event.accept()
return
- if self._showContextMenu(event.pos()):
+ if self._showContextMenu(posf):
event.accept()
else:
event.ignore()
return
if event.button() == Qt.MiddleButton:
- if self._startMiddlePan(event.pos()):
+ if self._startMiddlePan(posf):
event.accept()
return
self.geometry.ensure()
- pos = event.pos()
if event.button() == Qt.LeftButton:
toolbar_button = self._track_toolbar_button_at(pos)
@@ -2252,7 +2260,7 @@ def _handle_razor_press(self, pos):
return False
def _assign_press_target(self, event):
- pos = event.pos()
+ pos = _event_posf(event)
modifiers = event.modifiers() if hasattr(event, "modifiers") else Qt.NoModifier
ctrl = bool(modifiers & Qt.ControlModifier)
marker_entry = self._marker_at(pos)
@@ -2369,13 +2377,14 @@ def _start_scroll_drag_if_needed(self, pos):
def mouseMoveEvent(self, event):
self._last_event = event
+ posf = _event_posf(event)
if self.scroll_bar_dragging:
view_w = self.scrollbar_position[3] or 1.0
width_norm = self.scrollbar_position_previous[1] - self.scrollbar_position_previous[0]
handle_w = width_norm * view_w
avail = view_w - handle_w
- delta_px = self.mouse_position - event.pos().x()
+ delta_px = self.mouse_position - posf.x()
delta = 0.0
if avail > 0:
delta = (delta_px / avail) * (1.0 - width_norm)
@@ -2395,7 +2404,7 @@ def mouseMoveEvent(self, event):
height_norm = self.v_scrollbar_position_previous[1] - self.v_scrollbar_position_previous[0]
handle_h = height_norm * view_h
avail = view_h - handle_h
- delta_py = self.mouse_position - event.pos().y()
+ delta_py = self.mouse_position - posf.y()
delta = 0.0
if avail > 0:
delta = (delta_py / avail) * (1.0 - height_norm)
@@ -2408,33 +2417,33 @@ def mouseMoveEvent(self, event):
return
if self._middle_panning:
- self._updateMiddlePan(event.pos())
+ self._updateMiddlePan(posf)
return
- pos = event.pos()
if self._toolbar_pressed_key:
- self._update_toolbar_pressed_state(pos)
- self._update_toolbar_hover(pos)
+ self._update_toolbar_pressed_state(posf)
+ self._update_toolbar_hover(posf)
- self._updateCursor(pos)
+ self._updateCursor(posf)
self.events.moved.emit(event)
def mouseReleaseEvent(self, event):
self._last_event = event
+ posf = _event_posf(event)
if event.button() == Qt.LeftButton and self._toolbar_pressed_key:
button = self._get_toolbar_button(*self._toolbar_pressed_key)
inside = bool(
button
and button.get("rect")
- and button["rect"].contains(event.pos())
+ and button["rect"].contains(posf)
and self._toolbar_pressed_inside
)
self._toolbar_pressed_key = None
self._toolbar_pressed_inside = False
if inside and button:
self._activate_track_toolbar_button(button)
- self._update_toolbar_hover(event.pos())
+ self._update_toolbar_hover(posf)
self.update()
event.accept()
return
@@ -2515,7 +2524,7 @@ def mouseReleaseEvent(self, event):
marker_entry = self._press_marker
self._press_marker = None
if event.button() == Qt.LeftButton and isinstance(marker_entry, dict):
- current = self._marker_at(event.pos())
+ current = self._marker_at(posf)
if self._marker_same(marker_entry, current):
self._handle_marker_click(marker_entry)
self._press_hit = None
@@ -2527,17 +2536,18 @@ def mouseReleaseEvent(self, event):
self._press_hit = None
def contextMenuEvent(self, event):
- if self._panel_show_property_menu_at(event.pos()):
+ posf = _event_posf(event)
+ if self._panel_show_property_menu_at(posf):
event.accept()
return
- icon_entry = self._effect_icon_at(event.pos())
+ icon_entry = self._effect_icon_at(posf)
if icon_entry:
if self._trigger_effect_context_menu(
icon_entry, event.modifiers() if hasattr(event, "modifiers") else None
):
event.accept()
return
- if not self._showContextMenu(event.pos()):
+ if not self._showContextMenu(posf):
event.ignore()
def _panel_show_property_menu_at(self, pos):
diff --git a/src/windows/views/timeline_backend/qwidget/clip.py b/src/windows/views/timeline_backend/qwidget/clip.py
index 44db37206..e2634a169 100644
--- a/src/windows/views/timeline_backend/qwidget/clip.py
+++ b/src/windows/views/timeline_backend/qwidget/clip.py
@@ -27,8 +27,8 @@
import json
import uuid
-from PyQt5.QtCore import Qt, QRectF
-from PyQt5.QtWidgets import QApplication
+from qt_api import Qt, QRectF, QPointF
+from qt_api import QApplication
from classes.app import get_app
from classes.query import Clip, Transition
from classes.waveform import SAMPLES_PER_SECOND as WAVEFORM_SAMPLES_PER_SECOND
@@ -237,13 +237,13 @@ def _startClipDrag(self):
self.snap.reset()
self._drag_moved = False
- self._drag_press_pos = e.pos() if e else None
+ self._drag_press_pos = e.position() if (e and hasattr(e, "position")) else (QPointF(e.pos()) if e else None)
self._drag_threshold_met = False
# Identify the item under the cursor (include clips and transitions)
clicked_item = None
for rect, item, _selected, _type in self.geometry.iter_items(reverse=True):
- if rect.contains(e.pos()):
+ if rect.contains(self._drag_press_pos):
clicked_item = item
break
if clicked_item is None:
@@ -333,11 +333,12 @@ def _startClipDrag(self):
self.drag_bbox = self._compute_selected_bounding()
# Horizontal offset from cursor to bbox-left
- self.drag_clip_offset = e.pos().x() - self.drag_bbox.x()
+ self.drag_clip_offset = e.position().x() if hasattr(e, "position") else e.pos().x()
+ self.drag_clip_offset -= self.drag_bbox.x()
# Starting track index
self._drag_layer_idx_start = int(
- (e.pos().y() - self.ruler_height) / self.vertical_factor
+ ((e.position().y() if hasattr(e, "position") else e.pos().y()) - self.ruler_height) / self.vertical_factor
)
def _dragMove(self):
@@ -349,7 +350,7 @@ def _dragMove(self):
if not getattr(self, "_drag_threshold_met", True):
anchor = getattr(self, "_drag_press_pos", None)
if anchor is not None and e is not None:
- delta = e.pos() - anchor
+ delta = (e.position() if hasattr(e, "position") else QPointF(e.pos())) - anchor
if delta.manhattanLength() < QApplication.startDragDistance():
return
self._drag_threshold_met = True
@@ -359,7 +360,7 @@ def _dragMove(self):
if pps <= 0.0:
return
- new_bbox_x = e.pos().x() - self.drag_clip_offset
+ new_bbox_x = (e.position().x() if hasattr(e, "position") else e.pos().x()) - self.drag_clip_offset
delta_sec = (new_bbox_x - self.drag_bbox.x()) / pps
# Snap horizontally ±1.5 s (pure x-axis)
@@ -368,7 +369,7 @@ def _dragMove(self):
# -------- Vertical delta (track indexes) ----
new_idx_under_cursor = int(
- (e.pos().y() - self.ruler_height) / self.vertical_factor
+ ((e.position().y() if hasattr(e, "position") else e.pos().y()) - self.ruler_height) / self.vertical_factor
)
delta_idx = new_idx_under_cursor - self._drag_layer_idx_start
@@ -569,7 +570,8 @@ def _finishClipDrag(self):
self.update()
self._release_cursor()
if self._last_event:
- self._updateCursor(self._last_event.pos())
+ posf = self._last_event.position() if hasattr(self._last_event, "position") else QPointF(self._last_event.pos())
+ self._updateCursor(posf)
self._drag_moved = False
self._drag_press_pos = None
self._drag_threshold_met = False
@@ -653,7 +655,7 @@ def _resizeMove(self):
elif self._press_hit == "timeline-handle":
self._projectResizeMove()
else:
- new_width = max(40, self._last_event.pos().x())
+ new_width = max(40, (self._last_event.position().x() if hasattr(self._last_event, "position") else self._last_event.pos().x()))
if new_width != self.track_name_width:
self.track_name_width = new_width
self.changed(None)
@@ -682,7 +684,7 @@ def _projectResizeMove(self):
event = self._last_event
if not event:
return
- new_duration = self._seconds_from_x(event.pos().x())
+ new_duration = self._seconds_from_x(event.position().x() if hasattr(event, "position") else event.pos().x())
new_duration = max(self._project_resize_min_duration, new_duration)
snapped = self._snap_time(new_duration)
if snapped < self._project_resize_min_duration:
@@ -847,7 +849,7 @@ def _compute_transition_resize(self, item):
pos = self._resize_initial["position"]
if self._resize_edge == "left":
- delta_sec = (event.pos().x() - rect.left()) / pps
+ delta_sec = ((event.position().x() if hasattr(event, "position") else event.pos().x()) - rect.left()) / pps
if self.enable_snapping:
delta_sec = self.snap.snap_edge(pos, delta_sec)
max_delta = width - min_len
@@ -860,7 +862,7 @@ def _compute_transition_resize(self, item):
new_end = (pos + width) - new_position
rect_left = self.track_name_width + new_position * pps
else:
- delta_sec = (event.pos().x() - rect.right()) / pps
+ delta_sec = ((event.position().x() if hasattr(event, "position") else event.pos().x()) - rect.right()) / pps
if self.enable_snapping:
delta_sec = self.snap.snap_edge(pos + width, delta_sec)
min_delta = -(width - min_len)
@@ -894,7 +896,7 @@ def _compute_clip_resize(self, item):
geom_rect = QRectF(world_rect)
return geom_rect, start, end, pos
- cursor_sec = self._seconds_from_x(event.pos().x())
+ cursor_sec = self._seconds_from_x(event.position().x() if hasattr(event, "position") else event.pos().x())
clip_span = max(end - start, min_len)
if self._resize_edge == "left":
@@ -1026,7 +1028,8 @@ def _finishItemResize(self):
self.changed(None)
self._release_cursor()
if self._last_event:
- self._updateCursor(self._last_event.pos())
+ posf = self._last_event.position() if hasattr(self._last_event, "position") else QPointF(self._last_event.pos())
+ self._updateCursor(posf)
if hasattr(self, "_resize_initial_world_rect"):
del self._resize_initial_world_rect
self._resize_clip_max_duration = None
@@ -1036,7 +1039,7 @@ def _finishItemResize(self):
def _startBoxSelect(self):
e = self._last_event
ctrl_down = bool(e.modifiers() & Qt.ControlModifier)
- self.box_start = e.pos()
+ self.box_start = e.position() if hasattr(e, "position") else QPointF(e.pos())
panel_lane = self._panel_lane_at(self.box_start)
panel_track = panel_lane.get("track") if panel_lane else self._panel_track_at_pos(self.box_start)
if panel_track is not None:
@@ -1053,7 +1056,10 @@ def _startBoxSelect(self):
self.selection_rect = QRectF()
def _boxMove(self):
- rect = QRectF(self.box_start, self._last_event.pos()).normalized()
+ rect = QRectF(
+ self.box_start,
+ self._last_event.position() if hasattr(self._last_event, "position") else QPointF(self._last_event.pos())
+ ).normalized()
if self._panel_box_track is not None:
bounds = self._panel_box_bounds
if isinstance(bounds, QRectF) and not bounds.isNull():
diff --git a/src/windows/views/timeline_backend/qwidget/effect.py b/src/windows/views/timeline_backend/qwidget/effect.py
index c2078de86..d5449804e 100644
--- a/src/windows/views/timeline_backend/qwidget/effect.py
+++ b/src/windows/views/timeline_backend/qwidget/effect.py
@@ -25,7 +25,7 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QPointF, QRectF, Qt
+from qt_api import QPointF, QRectF, Qt
from classes.query import Clip
diff --git a/src/windows/views/timeline_backend/qwidget/keyframe.py b/src/windows/views/timeline_backend/qwidget/keyframe.py
index bf32dcfe7..cbe2e7c9f 100644
--- a/src/windows/views/timeline_backend/qwidget/keyframe.py
+++ b/src/windows/views/timeline_backend/qwidget/keyframe.py
@@ -28,8 +28,8 @@
import json
import math
import uuid
-from PyQt5.QtCore import QPointF, QRectF, QTimer, Qt
-from PyQt5.QtGui import QColor
+from qt_api import QPointF, QRectF, QTimer, Qt
+from qt_api import QColor
from classes.app import get_app
from classes.query import Clip, Transition, Effect
from classes.query import Marker
diff --git a/src/windows/views/timeline_backend/qwidget/keyframe_panel.py b/src/windows/views/timeline_backend/qwidget/keyframe_panel.py
index 00668cce0..beb87759f 100644
--- a/src/windows/views/timeline_backend/qwidget/keyframe_panel.py
+++ b/src/windows/views/timeline_backend/qwidget/keyframe_panel.py
@@ -28,7 +28,7 @@
import json
import math
import uuid
-from PyQt5.QtCore import QPointF, QRectF, Qt, QTimer
+from qt_api import QPointF, QRectF, Qt, QTimer
from classes.app import get_app
from classes.logger import log
from classes.query import Clip, Transition, Effect
diff --git a/src/windows/views/timeline_backend/qwidget/playhead.py b/src/windows/views/timeline_backend/qwidget/playhead.py
index d2b9d23e9..09c33bfc9 100644
--- a/src/windows/views/timeline_backend/qwidget/playhead.py
+++ b/src/windows/views/timeline_backend/qwidget/playhead.py
@@ -25,7 +25,7 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QRectF
+from qt_api import QRectF, QPointF
from classes.app import get_app
@@ -153,14 +153,17 @@ def _playhead_hit(self, pos):
def _startPlayhead(self):
self.dragging_playhead = True
self._fix_cursor(self.cursors["hand"])
- self._move_playhead(self._last_event.pos().x())
+ posf = self._last_event.position() if hasattr(self._last_event, "position") else QPointF(self._last_event.pos())
+ self._move_playhead(posf.x())
def _playheadMove(self):
if self.dragging_playhead:
- self._move_playhead(self._last_event.pos().x())
+ posf = self._last_event.position() if hasattr(self._last_event, "position") else QPointF(self._last_event.pos())
+ self._move_playhead(posf.x())
def _finishPlayhead(self):
self.dragging_playhead = False
self._release_cursor()
if self._last_event:
- self._updateCursor(self._last_event.pos())
+ posf = self._last_event.position() if hasattr(self._last_event, "position") else QPointF(self._last_event.pos())
+ self._updateCursor(posf)
diff --git a/src/windows/views/timeline_backend/qwidget/thumbnails.py b/src/windows/views/timeline_backend/qwidget/thumbnails.py
index 4c91f906e..85dd3f9c9 100644
--- a/src/windows/views/timeline_backend/qwidget/thumbnails.py
+++ b/src/windows/views/timeline_backend/qwidget/thumbnails.py
@@ -27,7 +27,7 @@
from collections import deque
-from PyQt5.QtCore import QObject, QThread, pyqtSignal, pyqtSlot
+from qt_api import QObject, QThread, pyqtSignal, pyqtSlot
from classes.logger import log
from classes.thumbnail import GetThumbPath
@@ -107,6 +107,8 @@ def clear_pending(self):
def shutdown(self):
"""Stop the worker thread."""
+ if self._thread is None:
+ return
self._clear_jobs.emit()
was_running = self._thread.isRunning()
if was_running:
@@ -119,3 +121,6 @@ def shutdown(self):
)
if not stopped:
log.warning("Timeline thumbnail thread did not stop within 2 seconds")
+ self._worker.deleteLater()
+ self._thread.deleteLater()
+ self._thread = None
diff --git a/src/windows/views/timeline_backend/qwidget/track.py b/src/windows/views/timeline_backend/qwidget/track.py
index fbae09f4e..fac0fcac1 100644
--- a/src/windows/views/timeline_backend/qwidget/track.py
+++ b/src/windows/views/timeline_backend/qwidget/track.py
@@ -25,7 +25,7 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QRectF, QLocale
+from qt_api import QRectF, QLocale
from classes.app import get_app
TRACK_TOOLBAR_SPACING_REDUCTION = 2.0
@@ -193,6 +193,8 @@ def _get_toolbar_button(self, track_id, key):
def _track_toolbar_button_at(self, pos):
self.geometry.ensure()
+ if hasattr(pos, "toPointF"):
+ pos = pos.toPointF()
for _track_rect, track, name_rect in self.geometry.iter_tracks():
for button in self._track_toolbar_buttons(track, name_rect):
if button["rect"].contains(pos):
@@ -296,6 +298,8 @@ def _update_toolbar_hover(self, pos):
def _update_toolbar_pressed_state(self, pos):
if not self._toolbar_pressed_key:
return
+ if hasattr(pos, "toPointF"):
+ pos = pos.toPointF()
button = self._get_toolbar_button(*self._toolbar_pressed_key)
inside = bool(button and button.get("rect") and button["rect"].contains(pos))
if inside != self._toolbar_pressed_inside:
diff --git a/src/windows/views/timeline_backend/qwidget/transition.py b/src/windows/views/timeline_backend/qwidget/transition.py
index 2dd092d7f..be75d826f 100644
--- a/src/windows/views/timeline_backend/qwidget/transition.py
+++ b/src/windows/views/timeline_backend/qwidget/transition.py
@@ -25,7 +25,7 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QRectF
+from qt_api import QRectF
class TransitionInteractionMixin:
diff --git a/src/windows/views/timeline_backend/state.py b/src/windows/views/timeline_backend/state.py
index d7dc4a398..887950a1f 100644
--- a/src/windows/views/timeline_backend/state.py
+++ b/src/windows/views/timeline_backend/state.py
@@ -25,7 +25,7 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import QState, QStateMachine
+from qt_api import QState, QStateMachine
class DragState(QState):
diff --git a/src/windows/views/timeline_backend/theme.py b/src/windows/views/timeline_backend/theme.py
index 3375a3514..d3973fd9d 100644
--- a/src/windows/views/timeline_backend/theme.py
+++ b/src/windows/views/timeline_backend/theme.py
@@ -4,8 +4,8 @@
import re
from typing import Callable, Optional, Sequence, Tuple, Union
-from PyQt5.QtCore import QFile, QByteArray
-from PyQt5.QtGui import QColor, QPixmap
+from qt_api import QFile, QByteArray
+from qt_api import QColor, QPixmap
from classes.logger import log
from classes.info import PATH
diff --git a/src/windows/views/timeline_backend/webengine.py b/src/windows/views/timeline_backend/webengine.py
index f5d743024..7ec5e1ed2 100644
--- a/src/windows/views/timeline_backend/webengine.py
+++ b/src/windows/views/timeline_backend/webengine.py
@@ -33,10 +33,10 @@
from classes import info
from classes.logger import log
-from PyQt5.QtCore import QFileInfo, QUrl, Qt, QTimer
-from PyQt5.QtGui import QColor
-from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEnginePage
-from PyQt5.QtWebChannel import QWebChannel
+from qt_api import QFileInfo, QUrl, Qt, QTimer, QT_API
+from qt_api import QColor
+from qt_api import QWebEngineView, QWebEnginePage
+from qt_api import QWebChannel
class LoggingWebEnginePage(QWebEnginePage):
@@ -73,17 +73,47 @@ def __init__(self):
# Delete the webview when closed
self.setAttribute(Qt.WA_DeleteOnClose)
- # Enable smooth scrolling on timeline
- self.settings().setAttribute(self.settings().ScrollAnimatorEnabled, True)
+ # Enable smooth scrolling on timeline (Qt6 uses scoped enums)
+ settings = self.settings()
+ scroll_attr = getattr(settings, "ScrollAnimatorEnabled", None)
+ if scroll_attr is None:
+ web_attr = getattr(settings, "WebAttribute", None)
+ if web_attr and hasattr(web_attr, "ScrollAnimatorEnabled"):
+ scroll_attr = web_attr.ScrollAnimatorEnabled
+ if scroll_attr is not None:
+ settings.setAttribute(scroll_attr, True)
+
+ # Allow local content to access file URLs (Qt6 scoped enums)
+ local_attr = getattr(settings, "LocalContentCanAccessFileUrls", None)
+ if local_attr is None:
+ web_attr = getattr(settings, "WebAttribute", None)
+ if web_attr and hasattr(web_attr, "LocalContentCanAccessFileUrls"):
+ local_attr = web_attr.LocalContentCanAccessFileUrls
+ if local_attr is not None:
+ settings.setAttribute(local_attr, True)
+
+ # Allow local content to access remote URLs (file:// -> http:// thumbnails)
+ remote_attr = getattr(settings, "LocalContentCanAccessRemoteUrls", None)
+ if remote_attr is None:
+ web_attr = getattr(settings, "WebAttribute", None)
+ if web_attr and hasattr(web_attr, "LocalContentCanAccessRemoteUrls"):
+ remote_attr = web_attr.LocalContentCanAccessRemoteUrls
+ if remote_attr is not None:
+ settings.setAttribute(remote_attr, True)
# Set url from configuration (QUrl takes absolute paths for file system paths, create from QFileInfo)
self.webchannel = QWebChannel(self.page())
- self.setHtml(self.get_html(), QUrl.fromLocalFile(QFileInfo(self.html_path).absoluteFilePath()))
self.page().setWebChannel(self.webchannel)
+ self.setHtml(self.get_html(), QUrl.fromLocalFile(QFileInfo(self.html_path).absoluteFilePath()))
# Connect signal of javascript initialization to our javascript reference init function
log.info("WebEngine backend initializing")
self.page().loadStarted.connect(self.setup_js_data)
+ self.page().loadFinished.connect(self._load_finished)
+
+ def _load_finished(self, ok):
+ if not ok:
+ log.warning("WebEngine failed to load timeline HTML (%s)", self.html_path)
def run_js(self, code, callback=None, retries=0):
"""Run JS code async and optionally have a callback for response"""
@@ -105,7 +135,14 @@ def run_js(self, code, callback=None, retries=0):
return None
# Execute JS code
if callback:
- return self.page().runJavaScript(code, callback)
+ def _wrapped_callback(result):
+ callback(result)
+ if QT_API == "pyside6":
+ try:
+ return self.page().runJavaScript(code, 0, _wrapped_callback)
+ except TypeError:
+ return self.page().runJavaScript(code, _wrapped_callback)
+ return self.page().runJavaScript(code, _wrapped_callback)
# else
return self.page().runJavaScript(code)
diff --git a/src/windows/views/timeline_backend/webkit.py b/src/windows/views/timeline_backend/webkit.py
index c4afc9691..938c84afb 100644
--- a/src/windows/views/timeline_backend/webkit.py
+++ b/src/windows/views/timeline_backend/webkit.py
@@ -32,8 +32,8 @@
from classes import info
from classes.logger import log
-from PyQt5.QtCore import QFileInfo, QUrl, Qt, QTimer
-from PyQt5.QtWebKitWidgets import QWebView, QWebPage
+from qt_api import QFileInfo, QUrl, Qt, QTimer
+from qt_api import QWebView, QWebPage
class LoggingWebKitPage(QWebPage):
diff --git a/src/windows/views/titles_listview.py b/src/windows/views/titles_listview.py
index 6606f0161..537df45b0 100644
--- a/src/windows/views/titles_listview.py
+++ b/src/windows/views/titles_listview.py
@@ -26,8 +26,8 @@
"""
from classes import info
-from PyQt5.QtCore import QTimer, Qt, QModelIndex, QItemSelectionModel
-from PyQt5.QtWidgets import QListView
+from qt_api import QTimer, Qt, QModelIndex, QItemSelectionModel
+from qt_api import QListView
from windows.models.titles_model import TitlesModel, TitleRoles
diff --git a/src/windows/views/transitions_listview.py b/src/windows/views/transitions_listview.py
index e4dc88076..7ab83cc29 100644
--- a/src/windows/views/transitions_listview.py
+++ b/src/windows/views/transitions_listview.py
@@ -25,9 +25,10 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import Qt, QSize, QPoint, QRegExp
-from PyQt5.QtGui import QDrag
-from PyQt5.QtWidgets import QListView, QAbstractItemView
+from qt_api import Qt, QSize, QPoint
+from qt_api import clear_override_cursor
+from qt_api import QDrag
+from qt_api import QListView, QAbstractItemView
from classes import info
from classes.app import get_app
@@ -49,7 +50,7 @@ def contextMenuEvent(self, event):
menu = StyledContextMenu(parent=self)
menu.addAction(self.win.actionDetailsView)
- menu.popup(event.globalPos())
+ menu.show_at(event)
def startDrag(self, supportedActions):
""" Override startDrag method to display custom icon """
@@ -74,7 +75,11 @@ def startDrag(self, supportedActions):
drag.setMimeData(self.model().mimeData(selected))
drag.setPixmap(icon.pixmap(self.drag_item_size))
drag.setHotSpot(self.drag_item_center)
- drag.exec_()
+ exec_fn = getattr(drag, "exec", None) or getattr(drag, "exec_", None)
+ if exec_fn is None:
+ raise AttributeError("QDrag has no exec_/exec method")
+ exec_fn()
+ clear_override_cursor()
def filter_changed(self):
self.refresh_view()
@@ -82,10 +87,12 @@ def filter_changed(self):
def refresh_view(self):
"""Filter transitions with proxy class"""
filter_text = self.win.transitionsFilter.text()
- # Apply filter to the source proxy model (not the single-column wrapper)
- self.transition_model.proxy_model.setFilterRegExp(QRegExp(filter_text.replace(' ', '.*')))
+ from qt_api import make_filter_regex, set_proxy_filter
+ pattern = filter_text.replace(' ', '.*')
+ regex = make_filter_regex(pattern, case_insensitive=True)
+ set_proxy_filter(self.transition_model.proxy_model, regex)
self.transition_model.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive)
- self.transition_model.proxy_model.sort(Qt.AscendingOrder)
+ self.transition_model.proxy_model.sort(0, Qt.AscendingOrder)
def __init__(self, model):
# Invoke parent init
@@ -109,6 +116,8 @@ def __init__(self, model):
self.selectionModel().deleteLater()
self.setSelectionMode(QAbstractItemView.SingleSelection)
self.setSelectionBehavior(QAbstractItemView.SelectRows)
+ if hasattr(self, "setSelectionRectVisible"):
+ self.setSelectionRectVisible(False)
self.setSelectionModel(self.transition_model.list_selection_model)
# Setup header columns
diff --git a/src/windows/views/transitions_treeview.py b/src/windows/views/transitions_treeview.py
index 5c08bfeea..bbaae3b37 100644
--- a/src/windows/views/transitions_treeview.py
+++ b/src/windows/views/transitions_treeview.py
@@ -25,9 +25,10 @@
along with OpenShot Library. If not, see .
"""
-from PyQt5.QtCore import Qt, QSize, QPoint
-from PyQt5.QtGui import QDrag
-from PyQt5.QtWidgets import QTreeView, QAbstractItemView, QSizePolicy
+from qt_api import Qt, QSize, QPoint
+from qt_api import clear_override_cursor
+from qt_api import QDrag
+from qt_api import QTreeView, QAbstractItemView, QSizePolicy
from classes import info
from classes.app import get_app
@@ -47,7 +48,7 @@ def contextMenuEvent(self, event):
menu = StyledContextMenu(parent=self)
menu.addAction(self.win.actionThumbnailView)
- menu.popup(event.globalPos())
+ menu.show_at(event)
def startDrag(self, event):
""" Override startDrag method to display custom icon """
@@ -72,7 +73,11 @@ def startDrag(self, event):
drag.setMimeData(self.model().mimeData(selected))
drag.setPixmap(icon.pixmap(self.drag_item_size))
drag.setHotSpot(self.drag_item_center)
- drag.exec_()
+ exec_fn = getattr(drag, "exec", None) or getattr(drag, "exec_", None)
+ if exec_fn is None:
+ raise AttributeError("QDrag has no exec_/exec method")
+ exec_fn()
+ clear_override_cursor()
def refresh_columns(self):
"""Hide certain columns"""
diff --git a/src/windows/views/tutorial.py b/src/windows/views/tutorial.py
index 679d8ef96..9a6b47b58 100644
--- a/src/windows/views/tutorial.py
+++ b/src/windows/views/tutorial.py
@@ -27,11 +27,11 @@
import functools
-from PyQt5.QtCore import Qt, QPoint, QRectF, QTimer, QObject, QRect
-from PyQt5.QtGui import (
+from qt_api import Qt, QPoint, QPointF, QRectF, QTimer, QObject, QRect
+from qt_api import (
QColor, QPalette, QPen, QPainter, QPainterPath, QKeySequence,
)
-from PyQt5.QtWidgets import (
+from qt_api import (
QAction, QLabel, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QToolButton, QCheckBox,
)
@@ -47,67 +47,72 @@ class TutorialDialog(QWidget):
def paintEvent(self, event):
""" Custom paint event """
painter = QPainter(self)
- painter.setRenderHint(QPainter.Antialiasing)
-
- # Set correct margins based on left/right arrow
- arrow_width = 15
- if not self.draw_arrow_on_right:
- self.vbox.setContentsMargins(45, 10, 20, 10)
- else:
- self.vbox.setContentsMargins(20, 10, 45, 10)
-
- # Define rounded rectangle geometry
- corner_radius = 10
- if self.draw_arrow_on_right:
- # Rectangle starts at left edge; arrow is on the right
- rounded_rect = QRectF(0, 0, self.width() - arrow_width, self.height())
- else:
- # Rectangle shifted to the right; arrow is on the left
- rounded_rect = QRectF(arrow_width, 0, self.width() - arrow_width, self.height())
-
- # Clip to the rounded rectangle
- path = QPainterPath()
- path.addRoundedRect(rounded_rect, corner_radius, corner_radius)
- painter.setClipPath(path)
-
- # Fill background
- frameColor = QColor("#53a0ed")
- painter.setPen(QPen(frameColor, 1.2))
- painter.setBrush(self.palette().color(QPalette.Window))
- painter.drawRoundedRect(rounded_rect, corner_radius, corner_radius)
-
- # Disable clipping temporarily for the arrow
- painter.setClipping(False)
+ try:
+ painter.setRenderHint(QPainter.Antialiasing)
- # Draw arrow if needed
- if self.arrow:
- arrow_height = 15
- arrow_offset = 35
+ # Set correct margins based on left/right arrow
+ arrow_width = 15
+ if not self.draw_arrow_on_right:
+ self.vbox.setContentsMargins(45, 10, 20, 10)
+ else:
+ self.vbox.setContentsMargins(20, 10, 45, 10)
+ # Define rounded rectangle geometry
+ corner_radius = 10
if self.draw_arrow_on_right:
- # Arrow on the right side
- arrow_point = rounded_rect.topRight().toPoint() + QPoint(arrow_width, arrow_offset)
- arrow_top_corner = rounded_rect.topRight().toPoint() + QPoint(-1, arrow_offset - arrow_height)
- arrow_bottom_corner = rounded_rect.topRight().toPoint() + QPoint(-1, arrow_offset + arrow_height)
+ # Rectangle starts at left edge; arrow is on the right
+ rounded_rect = QRectF(0, 0, self.width() - arrow_width, self.height())
else:
- # Arrow on the left side
- arrow_point = rounded_rect.topLeft().toPoint() + QPoint(-arrow_width, arrow_offset)
- arrow_top_corner = rounded_rect.topLeft().toPoint() + QPoint(1, arrow_offset - arrow_height)
- arrow_bottom_corner = rounded_rect.topLeft().toPoint() + QPoint(1, arrow_offset + arrow_height)
+ # Rectangle shifted to the right; arrow is on the left
+ rounded_rect = QRectF(arrow_width, 0, self.width() - arrow_width, self.height())
- # Draw triangle (filled with the same background color as the window)
+ # Clip to the rounded rectangle
path = QPainterPath()
- path.moveTo(arrow_point) # Arrow tip
- path.lineTo(arrow_top_corner) # Top corner of the triangle
- path.lineTo(arrow_bottom_corner) # Bottom corner of the triangle
- path.closeSubpath()
- painter.fillPath(path, self.palette().color(QPalette.Window))
-
- # Draw the triangle's borders
- border_pen = QPen(frameColor, 1)
- painter.setPen(border_pen)
- painter.drawLine(arrow_point, arrow_top_corner) # Top triangle border
- painter.drawLine(arrow_point, arrow_bottom_corner) # Bottom triangle border
+ path.addRoundedRect(rounded_rect, corner_radius, corner_radius)
+ painter.setClipPath(path)
+
+ # Fill background
+ frameColor = QColor("#53a0ed")
+ painter.setPen(QPen(frameColor, 1.2))
+ painter.setBrush(self.palette().color(QPalette.Window))
+ painter.drawRoundedRect(rounded_rect, corner_radius, corner_radius)
+
+ # Disable clipping temporarily for the arrow
+ painter.setClipping(False)
+
+ # Draw arrow if needed
+ if self.arrow:
+ arrow_height = 15
+ arrow_offset = 35
+
+ if self.draw_arrow_on_right:
+ # Arrow on the right side (use QPointF for Qt6 compatibility)
+ base_point = rounded_rect.topRight()
+ arrow_point = QPointF(base_point.x() + arrow_width, base_point.y() + arrow_offset)
+ arrow_top_corner = QPointF(base_point.x() - 1, base_point.y() + arrow_offset - arrow_height)
+ arrow_bottom_corner = QPointF(base_point.x() - 1, base_point.y() + arrow_offset + arrow_height)
+ else:
+ # Arrow on the left side (use QPointF for Qt6 compatibility)
+ base_point = rounded_rect.topLeft()
+ arrow_point = QPointF(base_point.x() - arrow_width, base_point.y() + arrow_offset)
+ arrow_top_corner = QPointF(base_point.x() + 1, base_point.y() + arrow_offset - arrow_height)
+ arrow_bottom_corner = QPointF(base_point.x() + 1, base_point.y() + arrow_offset + arrow_height)
+
+ # Draw triangle (filled with the same background color as the window)
+ path = QPainterPath()
+ path.moveTo(arrow_point) # Arrow tip
+ path.lineTo(arrow_top_corner) # Top corner of the triangle
+ path.lineTo(arrow_bottom_corner) # Bottom corner of the triangle
+ path.closeSubpath()
+ painter.fillPath(path, self.palette().color(QPalette.Window))
+
+ # Draw the triangle's borders (convert QPointF to QPoint for drawLine)
+ border_pen = QPen(frameColor, 1)
+ painter.setPen(border_pen)
+ painter.drawLine(arrow_point, arrow_top_corner) # Top triangle border
+ painter.drawLine(arrow_point, arrow_bottom_corner) # Bottom triangle border
+ finally:
+ painter.end()
def checkbox_metrics_callback(self, state):
""" Callback for error and anonymous usage checkbox"""
@@ -136,11 +141,15 @@ def mouseReleaseEvent(self, event):
def __init__(self, widget_id, text, arrow, manager, *args):
super().__init__(*args)
- # Ensure frameless, floating behavior
- self.setWindowFlags(Qt.FramelessWindowHint | Qt.Tool | Qt.WindowStaysOnTopHint)
+ # Ensure frameless, in-window overlay behavior
+ self.setWindowFlags(Qt.FramelessWindowHint | Qt.SubWindow)
self.setAttribute(Qt.WA_NoSystemBackground, True)
self.setAttribute(Qt.WA_TranslucentBackground, True)
self.setAttribute(Qt.WA_DeleteOnClose, True)
+ if hasattr(Qt, "WA_ShowWithoutActivating"):
+ self.setAttribute(Qt.WA_ShowWithoutActivating, True)
+ if hasattr(Qt, "WA_AlwaysStackOnTop"):
+ self.setAttribute(Qt.WA_AlwaysStackOnTop, True)
# get translations
app = get_app()
@@ -228,7 +237,6 @@ def process(self):
# If a tutorial is already visible, just update it
if self.current_dialog:
# Respond to possible dock floats/moves
- self.dock.raise_()
self.re_position_dialog()
return
@@ -249,32 +257,34 @@ def process(self):
self.offset = QPoint(
int(tutorial_details["x"]),
int(tutorial_details["y"]))
- tutorial_dialog = TutorialDialog(tutorial_id, tutorial_details["text"], tutorial_details["arrow"], self)
+ tutorial_dialog = TutorialDialog(tutorial_id, tutorial_details["text"], tutorial_details["arrow"], self, self.win)
tutorial_dialog.setObjectName("tutorial")
# Connect signals
tutorial_dialog.btn_next_tip.clicked.connect(functools.partial(self.next_tip, tutorial_id))
tutorial_dialog.btn_close_tips.clicked.connect(functools.partial(self.hide_tips, tutorial_id, True))
- # Get previous dock contents
- old_widget = self.dock.widget()
-
- # Insert into tutorial dock
- self.dock.setWidget(tutorial_dialog)
self.current_dialog = tutorial_dialog
# Show dialog
- self.dock.adjustSize()
- self.dock.setEnabled(True)
- self.re_position_dialog()
- self.dock.show()
-
- # Delete old widget
- if old_widget:
- old_widget.close()
+ self.current_dialog.adjustSize()
+ self.current_dialog.setEnabled(True)
+ self.re_show_dialog()
+ # Delay positioning until after the window is shown
+ QTimer.singleShot(0, self.re_position_dialog)
break
+ def _get_associated_widgets(self, action):
+ """Get associated widgets from a QAction (Qt5/Qt6 compatible)."""
+ # Qt6 renamed associatedWidgets() to associatedObjects()
+ if hasattr(action, 'associatedWidgets'):
+ return action.associatedWidgets()
+ elif hasattr(action, 'associatedObjects'):
+ # Filter to only return QWidget instances
+ return [obj for obj in action.associatedObjects() if isinstance(obj, QWidget)]
+ return []
+
def get_object(self, object_id):
"""Get an object from the main window by object id"""
if object_id == "filesView":
@@ -293,16 +303,16 @@ def get_object(self, object_id):
return self.win.emojiListView
elif object_id == "actionPlay":
# Find play/pause button on transport controls toolbar
- for w in self.win.actionPlay.associatedWidgets():
+ for w in self._get_associated_widgets(self.win.actionPlay):
if isinstance(w, QToolButton) and w.isVisible():
return w
- for w in self.win.actionPause.associatedWidgets():
+ for w in self._get_associated_widgets(self.win.actionPause):
if isinstance(w, QToolButton) and w.isVisible():
return w
elif object_id == "export_button":
# Find export toolbar button on main window
- for w in reversed(self.win.actionExportVideo.associatedWidgets()):
- if isinstance(w, QToolButton) and w.isVisible() and w.parent() == self.win.toolBar:
+ for w in reversed(self._get_associated_widgets(self.win.actionExportVideo)):
+ if isinstance(w, QToolButton) and w.isVisible() and w.parent() == self.win.toolBar:
return w
def next_tip(self, tid):
@@ -341,8 +351,8 @@ def hide_tips(self, tid, user_clicked=False):
def close_dialogs(self):
""" Close any open tutorial dialogs """
if self.current_dialog:
- self.dock.hide()
- self.dock.setEnabled(False)
+ self.current_dialog.hide()
+ self.current_dialog.setEnabled(False)
self.current_dialog = None
def exit_manager(self):
@@ -363,14 +373,14 @@ def exit_manager(self):
def re_show_dialog(self):
""" Re show an active dialog """
if self.current_dialog:
- self.dock.update()
- self.dock.raise_()
- self.dock.show()
+ self.current_dialog.update()
+ self.current_dialog.show()
+ self.current_dialog.raise_()
def hide_dialog(self):
""" Hide an active dialog """
if self.current_dialog:
- self.dock.hide()
+ self.current_dialog.hide()
def re_position_dialog(self):
""" Reposition the tutorial dialog next to self.position_widget. """
@@ -389,17 +399,18 @@ def re_position_dialog(self):
# Compute both possible positions (arrow on left vs. arrow on right)
# NOTE: We do this BEFORE we actually move the dialog!
- position_arrow_left = self.position_widget.mapToGlobal(pos_rect.bottomRight())
- position_arrow_right = self.position_widget.mapToGlobal(pos_rect.bottomLeft()) - QPoint(
+ position_arrow_left = self.position_widget.mapTo(self.win, pos_rect.bottomRight())
+ position_arrow_right = self.position_widget.mapTo(self.win, pos_rect.bottomLeft()) - QPoint(
self.current_dialog.width(), 0)
# Decide which side is viable. For example, we can see if arrow-on-left
# would run off the right side of the screen. If it does, pick arrow-on-right.
- screen_rect = get_app().primaryScreen().availableGeometry()
- monitor_width = screen_rect.width()
+ parent_rect = self.win.rect()
+ right_edge = parent_rect.right()
+ left_edge = parent_rect.left()
- # If placing “arrow on left” means we’d exceed monitor width, we must switch to arrow on right
- would_exceed_right_edge = (position_arrow_left.x() + self.current_dialog.width()) > monitor_width
+ # If placing “arrow on left” means we’d exceed the right edge, switch to arrow on right
+ would_exceed_right_edge = (position_arrow_left.x() + self.current_dialog.width()) > right_edge
if would_exceed_right_edge:
final_position = position_arrow_right
arrow_on_right = True
@@ -407,6 +418,11 @@ def re_position_dialog(self):
final_position = position_arrow_left
arrow_on_right = False
+ # If arrow-on-right would push off the left edge, keep it on the left
+ if arrow_on_right and final_position.x() < left_edge:
+ final_position = position_arrow_left
+ arrow_on_right = False
+
# Update the dialog’s internal state (so paintEvent() knows how to draw it).
self.current_dialog.draw_arrow_on_right = arrow_on_right
@@ -417,7 +433,15 @@ def re_position_dialog(self):
self.current_dialog.vbox.setContentsMargins(45, 10, 20, 10)
# Move the dock exactly once, and raise it
- self.dock.move(final_position)
+ final_parent_position = final_position
+ # Clamp within main window client area to avoid cropping
+ parent_rect = self.win.rect()
+ max_x = max(parent_rect.left(), parent_rect.right() - self.current_dialog.width())
+ max_y = max(parent_rect.top(), parent_rect.bottom() - self.current_dialog.height())
+ clamped_x = min(max(final_parent_position.x(), parent_rect.left()), max_x)
+ clamped_y = min(max(final_parent_position.y(), parent_rect.top()), max_y)
+ final_parent_position = QPoint(clamped_x, clamped_y)
+ self.current_dialog.move(final_parent_position)
self.re_show_dialog()
def process_visibility(self):
@@ -433,7 +457,6 @@ def __init__(self, win, *args):
self.win = win
self.dock = win.dockTutorial
self.current_dialog = None
- self.dock.setParent(None)
# get translations
app = get_app()
@@ -516,6 +539,8 @@ def __init__(self, win, *args):
self.dock.setAttribute(Qt.WA_TranslucentBackground, True)
self.dock.setWindowFlags(Qt.FramelessWindowHint | Qt.Tool | Qt.WindowStaysOnTopHint)
self.dock.setFloating(True)
+ self.dock.hide()
+ self.dock.setEnabled(False)
# Timer for processing new tutorials
self.tutorial_timer = QTimer(self)
diff --git a/src/windows/views/zoom_slider.py b/src/windows/views/zoom_slider.py
index 7594509f1..bec625d00 100644
--- a/src/windows/views/zoom_slider.py
+++ b/src/windows/views/zoom_slider.py
@@ -27,17 +27,24 @@
import copy
import math
-from PyQt5.QtCore import (
- Qt, QCoreApplication, QRectF, QTimer, QSize
+from qt_api import (
+ Qt, QCoreApplication, QRectF, QTimer, QSize, QPointF
)
-from PyQt5.QtGui import (
- QPainter, QColor, QPen, QBrush, QCursor, QPainterPath, QIcon
+from qt_api import modifiers_has
+from qt_api import (
+ QPainter, QColor, QPen, QBrush, QCursor, QPainterPath, QIcon, QPalette
)
-from PyQt5.QtWidgets import QSizePolicy, QWidget
+from qt_api import QSizePolicy, QWidget
import openshot # Python module for libopenshot (required video editing module installed separately)
from classes import updates
+
+
+def _event_posf(event):
+ if hasattr(event, "position"):
+ return event.position()
+ return QPointF(event.pos())
from classes.app import get_app
from classes.query import Clip, Track, Transition, Marker
from classes.logger import log
@@ -56,6 +63,9 @@ def minimumSizeHint(self):
# This method is invoked by the UpdateManager each time a change happens (i.e UpdateInterface)
def changed(self, action):
+ from qt_api import isdeleted
+ if isdeleted(self):
+ return
# Ignore changes that don't affect this
if (action and len(action.key) >= 1 and action.key[0].lower() in ["files", "history", "profile"]) or self.ignore_updates:
return
@@ -136,7 +146,10 @@ def paintEvent(self, event, *args):
painter.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform | QPainter.TextAntialiasing, True)
# Fill the whole widget with the solid color (background solid color)
- background_color = self.palette().color(self.palette().Base)
+ base_role = getattr(QPalette, "Base", None)
+ if base_role is None:
+ base_role = QPalette.ColorRole.Base
+ background_color = self.palette().color(base_role)
painter.fillRect(event.rect(), background_color)
# Create pens / colors
@@ -264,17 +277,18 @@ def mousePressEvent(self, event):
event.accept()
self.mouse_pressed = True
self.mouse_dragging = False
- self.mouse_position = event.pos().x()
+ self.mouse_position = _event_posf(event).x()
self.scrollbar_position_previous = list(self.scrollbar_position) # copy, don't alias
def mouseReleaseEvent(self, event):
"""Capture mouse release event"""
event.accept()
+ posf = _event_posf(event)
# Handle the case where no dragging occurred (single click)
- if not self.mouse_dragging and not self.scroll_bar_rect.contains(event.pos()):
+ if not self.mouse_dragging and not self.scroll_bar_rect.contains(posf):
# Center the scroll region at the click position (if outside the selection)
- click_pos = event.pos().x() / self.width()
+ click_pos = posf.x() / self.width()
selection_width = self.scrollbar_position[1] - self.scrollbar_position[0]
half_width = selection_width / 2
@@ -333,9 +347,10 @@ def set_handle_limits(self, left_handle, right_handle, is_left=False):
def mouseMoveEvent(self, event):
"""Capture mouse events"""
event.accept()
+ posf = _event_posf(event)
# Get current mouse position
- mouse_pos = event.pos().x()
+ mouse_pos = posf.x()
if mouse_pos < 0:
mouse_pos = 0
elif mouse_pos > self.width():
@@ -344,11 +359,11 @@ def mouseMoveEvent(self, event):
# Set cursor (based on current mouse position)
drag_threshold = 5
if not self.mouse_dragging:
- if self.left_handle_rect.contains(event.pos()):
+ if self.left_handle_rect.contains(posf):
self.setCursor(self.cursors.get('resize_x'))
- elif self.right_handle_rect.contains(event.pos()):
+ elif self.right_handle_rect.contains(posf):
self.setCursor(self.cursors.get('resize_x'))
- elif self.scroll_bar_rect.contains(event.pos()):
+ elif self.scroll_bar_rect.contains(posf):
self.setCursor(self.cursors.get('move'))
else:
self.setCursor(Qt.ArrowCursor)
@@ -356,11 +371,11 @@ def mouseMoveEvent(self, event):
# Detect dragging (only if the user clicked and started dragging beyond the threshold)
if self.mouse_pressed and not self.mouse_dragging:
self.mouse_dragging = True
- if self.left_handle_rect.contains(event.pos()):
+ if self.left_handle_rect.contains(posf):
self.left_handle_dragging = True
- elif self.right_handle_rect.contains(event.pos()):
+ elif self.right_handle_rect.contains(posf):
self.right_handle_dragging = True
- elif self.scroll_bar_rect.contains(event.pos()):
+ elif self.scroll_bar_rect.contains(posf):
self.scroll_bar_dragging = True
elif abs(self.mouse_position - mouse_pos) > drag_threshold:
# If clicking outside the current selection, initiate drag to create a new selection
@@ -376,7 +391,7 @@ def mouseMoveEvent(self, event):
new_left_pos = self.scrollbar_position_previous[0] - delta
is_left = True
- if int(QCoreApplication.instance().keyboardModifiers() & Qt.ShiftModifier) > 0:
+ if modifiers_has(QCoreApplication.instance().keyboardModifiers(), Qt.ShiftModifier):
# SHIFT key pressed, move both handles
if (self.scrollbar_position_previous[1] + delta) - new_left_pos > self.min_distance:
new_right_pos = self.scrollbar_position_previous[1] + delta
@@ -399,7 +414,7 @@ def mouseMoveEvent(self, event):
new_right_pos = self.scrollbar_position_previous[1] - delta
is_left = False
- if int(QCoreApplication.instance().keyboardModifiers() & Qt.ShiftModifier) > 0:
+ if modifiers_has(QCoreApplication.instance().keyboardModifiers(), Qt.ShiftModifier):
# SHIFT key pressed, move both handles
if new_right_pos - (self.scrollbar_position_previous[0] + delta) > self.min_distance:
new_left_pos = self.scrollbar_position_previous[0] + delta
@@ -587,7 +602,7 @@ def ignore_updates_callback(self, ignore, show_wait=True):
def __init__(self, *args):
# Invoke parent init
- QWidget.__init__(self, *args)
+ super().__init__(*args)
# Translate object
_ = get_app()._tr