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