From cd16c4912059a606713933eca5c6dcd165eeb113 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Wed, 17 Dec 2025 23:17:11 -0600 Subject: [PATCH 01/32] Replacing QRegExp with QRegularExpression for Qt6 compatibility in mind. Also, adding a new QT version shim, although we aren't using it quite yet. --- src/qt_api.py | 284 ++++++++++++++++++++++ src/windows/models/files_model.py | 7 +- src/windows/views/changelog_treeview.py | 4 +- src/windows/views/credits_treeview.py | 4 +- src/windows/views/effects_listview.py | 4 +- src/windows/views/emojis_listview.py | 4 +- src/windows/views/files_listview.py | 8 +- src/windows/views/profiles_treeview.py | 4 +- src/windows/views/transitions_listview.py | 4 +- 9 files changed, 307 insertions(+), 16 deletions(-) create mode 100644 src/qt_api.py diff --git a/src/qt_api.py b/src/qt_api.py new file mode 100644 index 000000000..d476ce7f4 --- /dev/null +++ b/src/qt_api.py @@ -0,0 +1,284 @@ +""" +Centralized Qt binding loader for OpenShot. + +Selects an available binding (PyQt6/PySide6/PyQt5/PySide2) using the +`OPENSHOT_QT_API` env var (`auto` default, otherwise one of +`pyqt6|pyside6|pyqt5|pyside2`). Logs the selection attempts, failures, +and final choice to help diagnose environment issues. +""" + +import logging +import os +from typing import List, Optional, Tuple + +logger = logging.getLogger(__name__) + + +# Public exports filled in after binding selection +QtCore = QtGui = QtWidgets = QtSvg = QtWebEngineWidgets = QtWebChannel = QtWebKitWidgets = None +Signal = Slot = Property = None +QRegularExpression = None +QT_API: Optional[str] = None +QT_VERSION_STR: Optional[str] = None +BINDING_VERSION_STR: Optional[str] = None + + +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", "pyside2"): + return [value] + return ["pyqt6", "pyside6", "pyqt5", "pyside2"] + + +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 + + QtSvgMod = None + QtWebEngineWidgetsMod = None + QtWebChannelMod = None + QtWebKitWidgetsMod = None + try: + import PyQt6.QtSvg as QtSvgMod # type: ignore + except Exception: + pass + try: + import PyQt6.QtWebEngineWidgets as QtWebEngineWidgetsMod # type: ignore + import PyQt6.QtWebChannel as QtWebChannelMod # type: ignore + except Exception: + pass + return ( + "pyqt6", + QtCoreMod, + QtGuiMod, + QtWidgetsMod, + QtSvgMod, + QtWebEngineWidgetsMod, + QtWebChannelMod, + QtWebKitWidgetsMod, + QtCoreMod.pyqtSignal, + QtCoreMod.pyqtSlot, + QtCoreMod.pyqtProperty, + QtCoreMod.QRegularExpression, + QtCoreMod.QT_VERSION_STR, + QtCoreMod.PYQT_VERSION_STR, + ) + + if name == "pyside6": + import PySide6.QtCore as QtCoreMod + import PySide6.QtGui as QtGuiMod + import PySide6.QtWidgets as QtWidgetsMod + + QtSvgMod = None + QtWebEngineWidgetsMod = None + QtWebChannelMod = None + QtWebKitWidgetsMod = None + try: + import PySide6.QtSvg as QtSvgMod # type: ignore + except Exception: + pass + try: + import PySide6.QtWebEngineWidgets as QtWebEngineWidgetsMod # type: ignore + import PySide6.QtWebChannel as QtWebChannelMod # type: ignore + except Exception: + pass + return ( + "pyside6", + QtCoreMod, + QtGuiMod, + QtWidgetsMod, + QtSvgMod, + QtWebEngineWidgetsMod, + QtWebChannelMod, + QtWebKitWidgetsMod, + QtCoreMod.Signal, + QtCoreMod.Slot, + QtCoreMod.Property, + QtCoreMod.QRegularExpression, + QtCoreMod.__version__, # PySide binds Qt version here + QtCoreMod.__version__, + ) + + if name == "pyqt5": + import PyQt5.QtCore as QtCoreMod + import PyQt5.QtGui as QtGuiMod + import PyQt5.QtWidgets as QtWidgetsMod + + QtSvgMod = None + QtWebEngineWidgetsMod = None + QtWebChannelMod = None + QtWebKitWidgetsMod = None + try: + import PyQt5.QtSvg as QtSvgMod # type: ignore + except Exception: + pass + try: + 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, + QtWebEngineWidgetsMod, + QtWebChannelMod, + QtWebKitWidgetsMod, + QtCoreMod.pyqtSignal, + QtCoreMod.pyqtSlot, + QtCoreMod.pyqtProperty, + QtCoreMod.QRegularExpression, + QtCoreMod.QT_VERSION_STR, + QtCoreMod.PYQT_VERSION_STR, + ) + + if name == "pyside2": + import PySide2.QtCore as QtCoreMod + import PySide2.QtGui as QtGuiMod + import PySide2.QtWidgets as QtWidgetsMod + + QtSvgMod = None + QtWebEngineWidgetsMod = None + QtWebChannelMod = None + QtWebKitWidgetsMod = None + try: + import PySide2.QtSvg as QtSvgMod # type: ignore + except Exception: + pass + try: + import PySide2.QtWebEngineWidgets as QtWebEngineWidgetsMod # type: ignore + import PySide2.QtWebChannel as QtWebChannelMod # type: ignore + except Exception: + pass + try: + import PySide2.QtWebKitWidgets as QtWebKitWidgetsMod # type: ignore + except Exception: + pass + return ( + "pyside2", + QtCoreMod, + QtGuiMod, + QtWidgetsMod, + QtSvgMod, + QtWebEngineWidgetsMod, + QtWebChannelMod, + QtWebKitWidgetsMod, + QtCoreMod.Signal, + QtCoreMod.Slot, + QtCoreMod.Property, + QtCoreMod.QRegularExpression, + QtCoreMod.__version__, + QtCoreMod.__version__, + ) + + raise ImportError(f"Unknown binding '{name}'") + + +def _select_binding() -> str: + """Select and load the first available binding.""" + global QtCore, QtGui, QtWidgets, QtSvg, QtWebEngineWidgets, QtWebChannel, QtWebKitWidgets + global Signal, Slot, Property, QRegularExpression, QT_API, QT_VERSION_STR, BINDING_VERSION_STR + + 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, + QtWebEngineWidgets, + QtWebChannel, + QtWebKitWidgets, + Signal, + Slot, + Property, + QRegularExpression, + QT_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, + ) + 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}") + + raise ImportError( + "No suitable Qt binding found. Tried: " + + ", ".join(errors) + + ". Set OPENSHOT_QT_API to force a specific binding." + ) + + +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 + + QtUiTools = import_module("PySide6.QtUiTools" if QT_API == "pyside6" else "PySide2.QtUiTools") # type: ignore + 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: + return loader.load(ui_file, baseinstance) + finally: + ui_file.close() + + +def ensure_binding(): + """Force binding selection (useful for early importers).""" + if QT_API is None: + _select_binding() + + +# Select binding immediately on import for visibility +ensure_binding() + +__all__ = [ + "QtCore", + "QtGui", + "QtWidgets", + "QtSvg", + "QtWebEngineWidgets", + "QtWebChannel", + "QtWebKitWidgets", + "Signal", + "Slot", + "Property", + "QRegularExpression", + "QT_API", + "QT_VERSION_STR", + "BINDING_VERSION_STR", + "ensure_binding", + "load_ui", +] diff --git a/src/windows/models/files_model.py b/src/windows/models/files_model.py index c9e4369f5..3d94b4334 100644 --- a/src/windows/models/files_model.py +++ b/src/windows/models/files_model.py @@ -79,8 +79,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 + # Match against regex pattern (Qt6-compatible) + regex = self.filterRegularExpression() + if regex.pattern(): + return regex.match(file_name).hasMatch() or regex.match(tags).hasMatch() + return True # Continue running built-in parent filter logic return super().filterAcceptsRow(sourceRow, sourceParent) diff --git a/src/windows/views/changelog_treeview.py b/src/windows/views/changelog_treeview.py index 2518ead96..b46b3a39b 100644 --- a/src/windows/views/changelog_treeview.py +++ b/src/windows/views/changelog_treeview.py @@ -29,7 +29,7 @@ import webbrowser from functools import partial -from PyQt5.QtCore import Qt, QRegExp +from PyQt5.QtCore import Qt, QRegularExpression from PyQt5.QtWidgets import QListView, QTreeView, QAbstractItemView, QSizePolicy, QHeaderView, QApplication from PyQt5.QtGui import QCursor @@ -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 0835c88b4..f1dc8b97f 100644 --- a/src/windows/views/credits_treeview.py +++ b/src/windows/views/credits_treeview.py @@ -27,7 +27,7 @@ """ import webbrowser -from PyQt5.QtCore import Qt, QRegExp +from PyQt5.QtCore import Qt, QRegularExpression from PyQt5.QtWidgets import QListView, QTreeView, QAbstractItemView, QSizePolicy, QHeaderView, QApplication from PyQt5.QtGui import QCursor from functools import partial @@ -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 31287c7f9..e6f131ac7 100644 --- a/src/windows/views/effects_listview.py +++ b/src/windows/views/effects_listview.py @@ -25,7 +25,7 @@ along with OpenShot Library. If not, see . """ -from PyQt5.QtCore import QSize, QPoint, Qt, QRegExp +from PyQt5.QtCore import QSize, QPoint, Qt, QRegularExpression from PyQt5.QtGui import QDrag from PyQt5.QtWidgets import QListView, QAbstractItemView @@ -81,7 +81,7 @@ def filter_changed(self): def refresh_view(self): """Filter transitions with proxy class""" filter_text = self.win.effectsFilter.text() - self.model().setFilterRegExp(QRegExp(filter_text.replace(' ', '.*'))) + self.model().setFilterRegularExpression(QRegularExpression(filter_text.replace(' ', '.*'))) self.model().setFilterCaseSensitivity(Qt.CaseInsensitive) self.model().sort(Qt.AscendingOrder) diff --git a/src/windows/views/emojis_listview.py b/src/windows/views/emojis_listview.py index 3a944d2e2..9c25ce461 100644 --- a/src/windows/views/emojis_listview.py +++ b/src/windows/views/emojis_listview.py @@ -25,7 +25,7 @@ along with OpenShot Library. If not, see . """ -from PyQt5.QtCore import QMimeData, QSize, QPoint, Qt, pyqtSlot, QRegExp +from PyQt5.QtCore import QMimeData, QSize, QPoint, Qt, pyqtSlot, QRegularExpression from PyQt5.QtGui import QDrag from PyQt5.QtWidgets import QListView @@ -129,7 +129,7 @@ def group_changed(self, index=-1): def filter_changed(self, filter_text=None): """Filter emoji with proxy class""" - self.model.setFilterRegExp(QRegExp(filter_text, Qt.CaseInsensitive)) + self.model.setFilterRegularExpression(QRegularExpression(filter_text, QRegularExpression.CaseInsensitiveOption)) self.model.setFilterKeyColumn(0) self.refresh_view() diff --git a/src/windows/views/files_listview.py b/src/windows/views/files_listview.py index f421d7f1a..d7db08270 100644 --- a/src/windows/views/files_listview.py +++ b/src/windows/views/files_listview.py @@ -26,7 +26,7 @@ along with OpenShot Library. If not, see . """ -from PyQt5.QtCore import QSize, Qt, QPoint, QRegExp +from PyQt5.QtCore import QSize, Qt, QPoint, QRegularExpression from PyQt5.QtGui import QDrag, QCursor, QPixmap, QPainter, QIcon from PyQt5.QtWidgets import QListView, QAbstractItemView @@ -214,7 +214,11 @@ def refresh_view(self): """Filter files with proxy class""" model = self.model() filter_text = self.win.filesFilter.text() - model.setFilterRegExp(QRegExp(filter_text.replace(' ', '.*'), Qt.CaseInsensitive)) + regex = QRegularExpression( + filter_text.replace(' ', '.*'), + QRegularExpression.CaseInsensitiveOption, + ) + model.setFilterRegularExpression(regex) col = model.sortColumn() model.sort(col) diff --git a/src/windows/views/profiles_treeview.py b/src/windows/views/profiles_treeview.py index 2a707adc0..8cc7a15cf 100644 --- a/src/windows/views/profiles_treeview.py +++ b/src/windows/views/profiles_treeview.py @@ -25,7 +25,7 @@ along with OpenShot Library. If not, see . """ -from PyQt5.QtCore import Qt, QItemSelectionModel, QRegExp, pyqtSignal, QTimer +from PyQt5.QtCore import Qt, QItemSelectionModel, QRegularExpression, pyqtSignal, QTimer from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QListView, QTreeView, QAbstractItemView, QSizePolicy, QAction @@ -59,7 +59,7 @@ 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().setFilterRegularExpression(QRegularExpression(filter_text.lower())) self.model().sort(Qt.DescendingOrder) # Format columns diff --git a/src/windows/views/transitions_listview.py b/src/windows/views/transitions_listview.py index 59fff02af..fb1ff3ea7 100644 --- a/src/windows/views/transitions_listview.py +++ b/src/windows/views/transitions_listview.py @@ -25,7 +25,7 @@ along with OpenShot Library. If not, see . """ -from PyQt5.QtCore import Qt, QSize, QPoint, QRegExp +from PyQt5.QtCore import Qt, QSize, QPoint, QRegularExpression from PyQt5.QtGui import QDrag from PyQt5.QtWidgets import QListView, QAbstractItemView @@ -82,7 +82,7 @@ def filter_changed(self): def refresh_view(self): """Filter transitions with proxy class""" filter_text = self.win.transitionsFilter.text() - self.model().setFilterRegExp(QRegExp(filter_text.replace(' ', '.*'))) + self.model().setFilterRegularExpression(QRegularExpression(filter_text.replace(' ', '.*'))) self.model().setFilterCaseSensitivity(Qt.CaseInsensitive) self.model().sort(Qt.AscendingOrder) From f91b09fed6f0f041904481027d64b8246dc2d4cd Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Thu, 18 Dec 2025 13:00:39 -0600 Subject: [PATCH 02/32] Initial integration of qt_api shim, to switch between different versions of PyQt5 and PyQt6. VERY Experimental. --- src/classes/app.py | 9 +- src/classes/clipboard.py | 2 +- src/classes/exporters/edl.py | 2 +- src/classes/exporters/final_cut_pro.py | 2 +- src/classes/importers/edl.py | 2 +- src/classes/importers/final_cut_pro.py | 2 +- src/classes/info.py | 8 +- src/classes/language.py | 2 +- src/classes/metrics.py | 2 +- src/classes/openshot_rc.py | 2 +- src/classes/qt_types.py | 2 +- src/classes/title_bar.py | 2 +- src/classes/ui_util.py | 8 +- src/classes/waveform.py | 6 +- src/language/openshot_lang.py | 2 +- src/language/show_translations.py | 2 +- src/language/test_translations.py | 2 +- src/launch.py | 10 +- src/qt_api.py | 182 +++++++++++++++++- src/tests/query_tests.py | 4 +- src/themes/base.py | 10 +- src/themes/cosmic/theme.py | 10 +- src/themes/humanity/theme.py | 2 +- src/windows/about.py | 4 +- src/windows/add_to_timeline.py | 4 +- src/windows/animated_title.py | 2 +- src/windows/animation.py | 2 +- src/windows/color_picker.py | 8 +- src/windows/cutting.py | 6 +- src/windows/export.py | 8 +- src/windows/export_clips.py | 6 +- src/windows/file_properties.py | 2 +- src/windows/main_window.py | 6 +- src/windows/models/add_to_timeline_model.py | 4 +- src/windows/models/blender_model.py | 4 +- src/windows/models/changelog_model.py | 4 +- src/windows/models/credits_model.py | 4 +- src/windows/models/effects_model.py | 8 +- src/windows/models/emoji_model.py | 84 ++++---- src/windows/models/files_model.py | 8 +- src/windows/models/profiles_model.py | 4 +- src/windows/models/properties_model.py | 4 +- src/windows/models/titles_model.py | 7 +- src/windows/models/transition_model.py | 7 +- src/windows/preferences.py | 6 +- src/windows/preview_thread.py | 14 +- src/windows/process_effect.py | 6 +- src/windows/profile.py | 4 +- src/windows/profile_edit.py | 2 +- src/windows/region.py | 4 +- src/windows/title_editor.py | 6 +- src/windows/video_widget.py | 6 +- src/windows/views/add_to_timeline_treeview.py | 4 +- src/windows/views/blender_listview.py | 6 +- src/windows/views/changelog_treeview.py | 6 +- src/windows/views/credits_treeview.py | 6 +- src/windows/views/effects_listview.py | 6 +- src/windows/views/effects_treeview.py | 6 +- src/windows/views/emojis_listview.py | 116 ++++++----- src/windows/views/files_listview.py | 6 +- src/windows/views/files_treeview.py | 6 +- src/windows/views/find_file.py | 2 +- src/windows/views/menu.py | 6 +- src/windows/views/profiles_treeview.py | 6 +- src/windows/views/properties_tableview.py | 18 +- src/windows/views/repeat.py | 2 +- src/windows/views/timeline.py | 6 +- src/windows/views/timeline_backend/colors.py | 2 +- .../views/timeline_backend/geometry/base.py | 2 +- .../views/timeline_backend/geometry/clip.py | 2 +- .../views/timeline_backend/geometry/marker.py | 2 +- .../views/timeline_backend/geometry/track.py | 2 +- .../timeline_backend/geometry/transition.py | 2 +- .../timeline_backend/paint/background.py | 4 +- .../views/timeline_backend/paint/base.py | 5 +- .../views/timeline_backend/paint/cache.py | 4 +- .../views/timeline_backend/paint/clip.py | 8 +- .../views/timeline_backend/paint/keyframe.py | 4 +- .../timeline_backend/paint/keyframepanel.py | 4 +- .../views/timeline_backend/paint/marker.py | 4 +- .../views/timeline_backend/paint/playhead.py | 4 +- .../views/timeline_backend/paint/ruler.py | 4 +- .../views/timeline_backend/paint/scrollbar.py | 4 +- .../views/timeline_backend/paint/selection.py | 4 +- .../views/timeline_backend/paint/track.py | 4 +- .../timeline_backend/paint/transition.py | 4 +- .../views/timeline_backend/qwidget/base.py | 23 +-- .../views/timeline_backend/qwidget/clip.py | 4 +- .../views/timeline_backend/qwidget/effect.py | 2 +- .../timeline_backend/qwidget/keyframe.py | 4 +- .../qwidget/keyframe_panel.py | 2 +- .../timeline_backend/qwidget/playhead.py | 2 +- .../timeline_backend/qwidget/thumbnails.py | 2 +- .../views/timeline_backend/qwidget/track.py | 2 +- .../timeline_backend/qwidget/transition.py | 2 +- src/windows/views/timeline_backend/state.py | 2 +- src/windows/views/timeline_backend/theme.py | 4 +- .../views/timeline_backend/webengine.py | 8 +- src/windows/views/timeline_backend/webkit.py | 4 +- src/windows/views/titles_listview.py | 4 +- src/windows/views/transitions_listview.py | 6 +- src/windows/views/transitions_treeview.py | 6 +- src/windows/views/tutorial.py | 6 +- src/windows/views/zoom_slider.py | 6 +- 104 files changed, 524 insertions(+), 345 deletions(-) diff --git a/src/classes/app.py b/src/classes/app.py index 78d96616f..36f3dbc34 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). @@ -169,8 +169,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,7 +334,7 @@ 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") 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..808a039fc 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,6 +88,10 @@ 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)") @@ -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'], 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 5792c8d77..6e2a851f5 100644 --- a/src/classes/metrics.py +++ b/src/classes/metrics.py @@ -41,7 +41,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/title_bar.py b/src/classes/title_bar.py index dbc2d94d3..15abf9e85 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 class HiddenTitleBar(QWidget): diff --git a/src/classes/ui_util.py b/src/classes/ui_util.py index 394f298bd..6eef7864d 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 from classes.app import get_app from classes.logger import log 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 6daf1175f..204551349 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\x01\xff\xca\ 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 08829ae52..e2c97500f 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..39d458739 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 diff --git a/src/qt_api.py b/src/qt_api.py index d476ce7f4..be8ad3a64 100644 --- a/src/qt_api.py +++ b/src/qt_api.py @@ -18,9 +18,59 @@ QtCore = QtGui = QtWidgets = QtSvg = 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 BINDING_VERSION_STR: Optional[str] = None +_MODULES = [] +_FAILED_IMPORT: Optional[Exception] = None +_SELECTING = 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 def _binding_order(env_value: str) -> List[str]: @@ -37,7 +87,21 @@ def _import_binding(name: str) -> Tuple: 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 QtWebEngineWidgetsMod = None QtWebChannelMod = None @@ -64,6 +128,9 @@ def _import_binding(name: str) -> Tuple: QtCoreMod.pyqtSlot, QtCoreMod.pyqtProperty, QtCoreMod.QRegularExpression, + q_state, + q_state_machine, + uicMod, QtCoreMod.QT_VERSION_STR, QtCoreMod.PYQT_VERSION_STR, ) @@ -72,7 +139,18 @@ def _import_binding(name: str) -> Tuple: 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 QtWebEngineWidgetsMod = None QtWebChannelMod = None @@ -99,6 +177,9 @@ def _import_binding(name: str) -> Tuple: QtCoreMod.Slot, QtCoreMod.Property, QtCoreMod.QRegularExpression, + q_state, + q_state_machine, + QtUiToolsMod, QtCoreMod.__version__, # PySide binds Qt version here QtCoreMod.__version__, ) @@ -107,6 +188,11 @@ def _import_binding(name: str) -> Tuple: 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 QtWebEngineWidgetsMod = None @@ -138,6 +224,9 @@ def _import_binding(name: str) -> Tuple: QtCoreMod.pyqtSlot, QtCoreMod.pyqtProperty, QtCoreMod.QRegularExpression, + q_state, + q_state_machine, + uicMod, QtCoreMod.QT_VERSION_STR, QtCoreMod.PYQT_VERSION_STR, ) @@ -146,6 +235,17 @@ def _import_binding(name: str) -> Tuple: import PySide2.QtCore as QtCoreMod import PySide2.QtGui as QtGuiMod import PySide2.QtWidgets as QtWidgetsMod + QtUiToolsMod = None + try: + import PySide2.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("PySide2 QtStateMachine module not available (QState/QStateMachine missing)") QtSvgMod = None QtWebEngineWidgetsMod = None @@ -177,6 +277,9 @@ def _import_binding(name: str) -> Tuple: QtCoreMod.Slot, QtCoreMod.Property, QtCoreMod.QRegularExpression, + q_state, + q_state_machine, + QtUiToolsMod, QtCoreMod.__version__, QtCoreMod.__version__, ) @@ -187,7 +290,15 @@ def _import_binding(name: str) -> Tuple: def _select_binding() -> str: """Select and load the first available binding.""" global QtCore, QtGui, QtWidgets, QtSvg, QtWebEngineWidgets, QtWebChannel, QtWebKitWidgets - global Signal, Slot, Property, QRegularExpression, QT_API, QT_VERSION_STR, BINDING_VERSION_STR + global Signal, Slot, Property, QRegularExpression, QState, QStateMachine, uic, QT_API, QT_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) @@ -209,6 +320,9 @@ def _select_binding() -> str: Slot, Property, QRegularExpression, + QState, + QStateMachine, + uic, QT_VERSION_STR, BINDING_VERSION_STR, ) = _import_binding(candidate) @@ -218,16 +332,34 @@ def _select_binding() -> str: QT_VERSION_STR, BINDING_VERSION_STR, ) + _MODULES = [ + m + for m in ( + QtCore, + QtGui, + QtWidgets, + QtSvg, + 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}") - raise ImportError( + _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): @@ -259,6 +391,43 @@ 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: + import PySide2.QtStateMachine as QtStateMachine # type: ignore + QState = getattr(QtStateMachine, "QState", None) + QStateMachine = getattr(QtStateMachine, "QStateMachine", None) + except Exception: + pass + return QState if name == "QState" else QStateMachine + for module in _MODULES: + if hasattr(module, name): + return getattr(module, name) + raise AttributeError(name) # Select binding immediately on import for visibility @@ -276,6 +445,15 @@ def ensure_binding(): "Slot", "Property", "QRegularExpression", + "QState", + "QStateMachine", + # Commonly used Qt types + "QSignalTransition", + "QState", + "QStateMachine", + "QByteArray", + "QDir", + "QLibraryInfo", "QT_API", "QT_VERSION_STR", "BINDING_VERSION_STR", 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 d0c27a653..3d08107fd 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 @@ -194,7 +194,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 c9e32cb37..93539608c 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 @@ -506,8 +506,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 597fd2e3c..c5833913b 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..38f0cd263 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 diff --git a/src/windows/add_to_timeline.py b/src/windows/add_to_timeline.py index 62b7c14de..a64dfae31 100644 --- a/src/windows/add_to_timeline.py +++ b/src/windows/add_to_timeline.py @@ -31,8 +31,8 @@ from operator import itemgetter from random import shuffle, randint, uniform -from PyQt5.QtWidgets import QDialog -from PyQt5.QtGui import QIcon +from qt_api import QDialog +from qt_api import QIcon from classes import info, ui_util, time_parts from classes.logger import log diff --git a/src/windows/animated_title.py b/src/windows/animated_title.py index 9754bcd2b..f297b3e15 100644 --- a/src/windows/animated_title.py +++ b/src/windows/animated_title.py @@ -29,7 +29,7 @@ import os import uuid -from PyQt5.QtWidgets import ( +from qt_api import ( QApplication, QDialog, QDialogButtonBox, QPushButton ) diff --git a/src/windows/animation.py b/src/windows/animation.py index 0cf7ab205..481396551 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 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 1d4f9bb5d..3fc114288 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 diff --git a/src/windows/export.py b/src/windows/export.py index 165582ff3..3288fe03d 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, pyqtSignal, pyqtSlot -from PyQt5.QtWidgets import ( +from qt_api import Qt, QCoreApplication, QTimer, QSize, 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 from classes import ui_util @@ -1207,7 +1207,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 3a29c52f0..2c2362ec5 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 +from qt_api import QPushButton, QDialog, QDialogButtonBox, QLabel, QFileDialog, QMessageBox +from qt_api import Qt from classes import ui_util from classes import info from classes.app import get_app @@ -169,7 +169,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..6af87d4dd 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, ) diff --git a/src/windows/main_window.py b/src/windows/main_window.py index c8782dedf..37ebdf643 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 ) -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, 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 f007991a0..4030e0c58 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, ) -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 @@ -236,7 +234,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 f6eb315a5..934200b07 100644 --- a/src/windows/models/emoji_model.py +++ b/src/windows/models/emoji_model.py @@ -27,9 +27,8 @@ import os -from PyQt5.QtCore import QMimeData, Qt, QSortFilterProxyModel -from PyQt5.QtGui import QStandardItemModel, QStandardItem, QIcon -from PyQt5.QtWidgets import QMessageBox +from qt_api import QMimeData, Qt, QSortFilterProxyModel, Signal, QRegularExpression, QMessageBox +from qt_api import QStandardItemModel, QStandardItem, QIcon import openshot # Python module for libopenshot (required video editing module installed separately) from classes import info @@ -60,7 +59,33 @@ def mimeData(self, indexes): return data -class EmojisModel(): +class EmojisModel(QSortFilterProxyModel): + ModelRefreshed = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.model = EmojiStandardItemModel() + self.model.setColumnCount(3) + self.setSourceModel(self.model) + self.emoji_groups = [] + self.model_paths = {} + # Configure proxy filtering/sorting + self.setDynamicSortFilter(True) + self.setFilterCaseSensitivity(Qt.CaseInsensitive) + self.setSortCaseSensitivity(Qt.CaseSensitive) + self.setSortLocaleAware(True) + self.setFilterKeyColumn(0) + self.group_filter = "" + self.text_regex = QRegularExpression() + + def set_group_filter(self, group_id: str): + self.group_filter = group_id or "" + self.invalidateFilter() + + def set_text_filter(self, pattern: str): + self.text_regex = QRegularExpression(pattern, QRegularExpression.CaseInsensitiveOption) + self.setFilterRegularExpression(self.text_regex) + def update_model(self, clear=True): log.info("updating emoji model.") app = get_app() @@ -172,43 +197,18 @@ def update_model(self, clear=True): self.model.appendRow(row) self.model_paths[path] = path - def __init__(self, *args): + self.ModelRefreshed.emit() - # Create standard model - self.app = get_app() - self.model = EmojiStandardItemModel() - self.model.setColumnCount(3) - self.model_paths = {} - self.emoji_groups = [] + def filterAcceptsRow(self, source_row, source_parent): + if self.group_filter: + group_idx = self.sourceModel().index(source_row, 2, source_parent) + if self.sourceModel().data(group_idx) != self.group_filter: + return False + + regex = self.filterRegularExpression() + if regex.pattern(): + name_idx = self.sourceModel().index(source_row, 0, source_parent) + value = self.sourceModel().data(name_idx) + return regex.match(value).hasMatch() - # Create proxy models (for grouping, sorting and filtering) - self.group_model = QSortFilterProxyModel() - self.group_model.setDynamicSortFilter(True) - self.group_model.setFilterCaseSensitivity(Qt.CaseInsensitive) - self.group_model.setSortCaseSensitivity(Qt.CaseSensitive) - self.group_model.setSourceModel(self.model) - self.group_model.setSortLocaleAware(True) - self.group_model.setFilterKeyColumn(1) - - self.proxy_model = QSortFilterProxyModel() - self.proxy_model.setDynamicSortFilter(True) - self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive) - self.proxy_model.setSortCaseSensitivity(Qt.CaseSensitive) - self.proxy_model.setSourceModel(self.group_model) - self.proxy_model.setSortLocaleAware(True) - - # 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 - self.model_tests = [] - for m in [self.proxy_model, self.group_model, self.model]: - self.model_tests.append( - QAbstractItemModelTester( - m, QAbstractItemModelTester.FailureReportingMode.Warning) - ) - log.info("Enabled {} model tests for emoji data".format(len(self.model_tests))) - except ImportError: - pass + return True diff --git a/src/windows/models/files_model.py b/src/windows/models/files_model.py index 3d94b4334..47a2edf58 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 ( +from qt_api import ( QMimeData, Qt, pyqtSignal, QEventLoop, QObject, QSortFilterProxyModel, QItemSelectionModel, 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 @@ -653,7 +653,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/profiles_model.py b/src/windows/models/profiles_model.py index c2359793b..6c8b534e0 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 diff --git a/src/windows/models/properties_model.py b/src/windows/models/properties_model.py index 2a91fd1f1..8b9fc0155 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 8405b56dd..fafe385f8 100644 --- a/src/windows/models/titles_model.py +++ b/src/windows/models/titles_model.py @@ -28,9 +28,8 @@ import os import fnmatch -from PyQt5.QtCore import Qt, QObject, QMimeData, QSortFilterProxyModel, QItemSelectionModel -from PyQt5.QtGui import QStandardItemModel, QStandardItem, QIcon -from PyQt5.QtWidgets import QMessageBox +from qt_api import Qt, QObject, QMimeData, QSortFilterProxyModel, QItemSelectionModel +from qt_api import QStandardItemModel, QStandardItem, QIcon import openshot # Python module for libopenshot (required video editing module installed separately) from classes import info @@ -201,7 +200,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 f27976b17..b1f2a2897 100644 --- a/src/windows/models/transition_model.py +++ b/src/windows/models/transition_model.py @@ -27,12 +27,11 @@ import os -from PyQt5.QtCore import ( +from qt_api import ( QObject, QMimeData, Qt, pyqtSignal, QSortFilterProxyModel, QPersistentModelIndex, QItemSelectionModel, ) -from PyQt5.QtGui import QIcon, QStandardItemModel, QStandardItem -from PyQt5.QtWidgets import QMessageBox +from qt_api import QIcon, QStandardItemModel, QStandardItem import openshot # Python module for libopenshot (required video editing module installed separately) from classes import info @@ -256,7 +255,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 61b311d84..b156f2bd4 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 ( +from qt_api import Qt, QSize, QDir +from qt_api import ( QWidget, QDialog, QMessageBox, QFileDialog, 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 from classes import openshot_rc # noqa diff --git a/src/windows/preview_thread.py b/src/windows/preview_thread.py index d7823af80..3758ab0ee 100644 --- a/src/windows/preview_thread.py +++ b/src/windows/preview_thread.py @@ -26,11 +26,19 @@ """ import time -import sip import math +try: + import sip # PyQt5 +except ImportError: + try: + from PyQt5 import sip # type: ignore + except Exception: + try: + from PyQt6 import sip # type: ignore + except Exception: + sip = None -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 import openshot # Python module for libopenshot (required video editing module installed separately) from classes.app import get_app diff --git a/src/windows/process_effect.py b/src/windows/process_effect.py index 162c67f9a..2e5610dad 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 diff --git a/src/windows/profile.py b/src/windows/profile.py index 8a0fb587f..f3ba23e40 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 from classes.app import get_app diff --git a/src/windows/profile_edit.py b/src/windows/profile_edit.py index 16623e43f..a01e95086 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 from classes.app import get_app from classes.logger import log diff --git a/src/windows/region.py b/src/windows/region.py index f4e556d97..7629c456f 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 diff --git a/src/windows/title_editor.py b/src/windows/title_editor.py index 837f96e70..0bf0b560d 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 diff --git a/src/windows/video_widget.py b/src/windows/video_widget.py index 45cfb0f46..be5293ff5 100644 --- a/src/windows/video_widget.py +++ b/src/windows/video_widget.py @@ -30,14 +30,14 @@ import time import uuid -from PyQt5.QtCore import ( +from qt_api import ( Qt, QCoreApplication, QMutex, QTimer, QPoint, QPointF, QSize, QSizeF, QRect, QRectF, ) -from PyQt5.QtGui import ( +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) diff --git a/src/windows/views/add_to_timeline_treeview.py b/src/windows/views/add_to_timeline_treeview.py index c56cdd332..0f97a27a1 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 532f0e844..8d3f5e437 100644 --- a/src/windows/views/blender_listview.py +++ b/src/windows/views/blender_listview.py @@ -40,14 +40,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 b46b3a39b..c8968bb9c 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, QRegularExpression -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 diff --git a/src/windows/views/credits_treeview.py b/src/windows/views/credits_treeview.py index f1dc8b97f..fe0b985c3 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, QRegularExpression -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 diff --git a/src/windows/views/effects_listview.py b/src/windows/views/effects_listview.py index e6f131ac7..a77e42fd7 100644 --- a/src/windows/views/effects_listview.py +++ b/src/windows/views/effects_listview.py @@ -25,9 +25,9 @@ along with OpenShot Library. If not, see . """ -from PyQt5.QtCore import QSize, QPoint, Qt, QRegularExpression -from PyQt5.QtGui import QDrag -from PyQt5.QtWidgets import QListView, QAbstractItemView +from qt_api import QSize, QPoint, Qt, QRegularExpression +from qt_api import QDrag +from qt_api import QListView, QAbstractItemView from classes import info from classes.app import get_app diff --git a/src/windows/views/effects_treeview.py b/src/windows/views/effects_treeview.py index 2cefe691a..e2ebcd8f0 100644 --- a/src/windows/views/effects_treeview.py +++ b/src/windows/views/effects_treeview.py @@ -25,9 +25,9 @@ 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 QDrag +from qt_api import QTreeView, QAbstractItemView, QSizePolicy from classes import info from classes.app import get_app diff --git a/src/windows/views/emojis_listview.py b/src/windows/views/emojis_listview.py index 9c25ce461..4f809178b 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, QRegularExpression -from PyQt5.QtGui import QDrag -from PyQt5.QtWidgets import QListView - +import os +from qt_api import QMimeData, QSize, QPoint, Qt, pyqtSlot, QRegularExpression +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 @@ -64,6 +63,9 @@ def startDrag(self, event): # Create emoji file before drag starts data = json.loads(drag.mimeData().text()) file = self.add_file(data[0]) + if not file: + log.warning("Failed to add emoji file for drag: %s", data[0]) + return # Update mimedata for emoji data = QMimeData() @@ -110,83 +112,75 @@ def add_file(self, filepath): # 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 - 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) + def filter_changed(self, text): + self.model.set_text_filter(text) - self.refresh_view() + def group_changed(self, index): + group_id = self.win.emojiFilterGroup.itemData(index) + self.model.set_group_filter(group_id or "") - @pyqtSlot(str) - def filter_changed(self, filter_text=None): - """Filter emoji with proxy class""" + def refresh_view(self): + """Filter emojis with proxy class""" - self.model.setFilterRegularExpression(QRegularExpression(filter_text, QRegularExpression.CaseInsensitiveOption)) - self.model.setFilterKeyColumn(0) - self.refresh_view() + col = self.model.sortColumn() + self.model.sort(col) - def refresh_view(self): - # Sort by column 0 - self.model.sort(0) + def resize_contents(self): + pass + + @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) + + # Add emoji to project (after checking if not found in project) + if file_path not in info.EMOJI_FILES: + self.add_file(file_path) - def __init__(self, model): + # 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 - self.emojis_model = model - self.group_model = self.emojis_model.group_model - self.model = self.emojis_model.proxy_model + # Set model (expects a proxy model) + self.model = model + self.setModel(self.model) + + # Configure selection behavior + self.setSelectionMode(QListView.ExtendedSelection) + self.setSelectionBehavior(QListView.SelectRows) # Keep track of mouse press start position to determine when to start drag self.setAcceptDrops(True) self.setDragEnabled(True) self.setDropIndicatorShown(True) - # Setup header columns - self.setModel(self.model) - self.setIconSize(info.EMOJI_ICON_SIZE) - self.setGridSize(info.EMOJI_GRID_SIZE) + # Setup header columns and layout + self.setIconSize(info.LIST_ICON_SIZE) + self.setGridSize(info.LIST_GRID_SIZE) self.setViewMode(QListView.IconMode) self.setResizeMode(QListView.Adjust) self.setUniformItemSizes(True) - self.setWordWrap(False) self.setStyleSheet('QListView::item { padding-top: 2px; }') + 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.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"), "") - 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 - dropdown_index = index + 1 - + self.win.emojiFilterGroup.addItem(_("All"), "") + for name, group_id in sorted(self.model.emoji_groups, key=lambda g: g[0]): + self.win.emojiFilterGroup.addItem(name, group_id) 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 d7db08270..45a4dbc89 100644 --- a/src/windows/views/files_listview.py +++ b/src/windows/views/files_listview.py @@ -26,9 +26,9 @@ along with OpenShot Library. If not, see . """ -from PyQt5.QtCore import QSize, Qt, QPoint, QRegularExpression -from PyQt5.QtGui import QDrag, QCursor, QPixmap, QPainter, QIcon -from PyQt5.QtWidgets import QListView, QAbstractItemView +from qt_api import QSize, Qt, QPoint, QRegularExpression +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 diff --git a/src/windows/views/files_treeview.py b/src/windows/views/files_treeview.py index d0d4485de..edf4ea676 100644 --- a/src/windows/views/files_treeview.py +++ b/src/windows/views/files_treeview.py @@ -29,9 +29,9 @@ import os -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 QDrag, QCursor, QPixmap, QPainter, QIcon +from qt_api import QTreeView, QAbstractItemView, QSizePolicy, QHeaderView from classes import info from classes.app import get_app diff --git a/src/windows/views/find_file.py b/src/windows/views/find_file.py index cea548070..40499a06f 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..2d6f62772 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 diff --git a/src/windows/views/profiles_treeview.py b/src/windows/views/profiles_treeview.py index 8cc7a15cf..bc2e8ab62 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, QRegularExpression, 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 diff --git a/src/windows/views/properties_tableview.py b/src/windows/views/properties_tableview.py index 18bc71574..6b7f368d2 100644 --- a/src/windows/views/properties_tableview.py +++ b/src/windows/views/properties_tableview.py @@ -29,15 +29,25 @@ import json import functools from operator import itemgetter -import sip import uuid +try: + import sip # PyQt5 +except ImportError: + try: + from PyQt5 import sip # type: ignore + except Exception: + try: + from PyQt6 import sip # type: ignore + except Exception: + # Defer failure to later use-sites for clearer traceback + sip = None -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 +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 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 57c32a2ba..dad5ff031 100644 --- a/src/windows/views/timeline.py +++ b/src/windows/views/timeline.py @@ -38,9 +38,9 @@ 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 QCursor, QKeySequence +from qt_api import QDialog from classes import info, updates from classes.app import 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 056c563e0..2e103836f 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 5f4b78a08..d532bd641 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 QImage, QPainter, QPixmap -from PyQt5.QtSvg import QSvgRenderer import math +from qt_api import QRectF, Qt, QSvgRenderer +from qt_api import QImage, QPainter, QPixmap 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 bb831bacd..e3ac1be98 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 @@ -1380,7 +1380,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 5836efa47..1e6f92c5f 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.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..149363130 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, 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 a1dc539b4..809ad03f9 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 821d6da82..5057f4686 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 00e5ebe32..91f0f9ad4 100644 --- a/src/windows/views/timeline_backend/qwidget/base.py +++ b/src/windows/views/timeline_backend/qwidget/base.py @@ -28,25 +28,16 @@ import json from functools import partial -from PyQt5.QtCore import ( - Qt, - QRectF, - QSize, - QTimer, - QPointF, - QSignalTransition, - QByteArray, - pyqtSignal, - QObject, - QMetaMethod, -) -from PyQt5.QtGui import ( +from qt_api import Qt, QRectF, QSize, QTimer, QPointF, QByteArray, pyqtSignal, QObject, QMetaMethod +from qt_api import QtCore +from qt_api import QtCore +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 ( @@ -104,7 +95,7 @@ def _collect_signal_signatures(qobject_type): _TIMELINE_EVENT_SIGNATURES = _collect_signal_signatures(TimelineEvents) -class _ConditionalTransition(QSignalTransition): +class _ConditionalTransition(QtCore.QSignalTransition): def __init__(self, sender, signal_bytes, source_state, target_state, condition): """Create a QSignalTransition that evaluates a condition before firing.""" @@ -480,7 +471,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)) diff --git a/src/windows/views/timeline_backend/qwidget/clip.py b/src/windows/views/timeline_backend/qwidget/clip.py index 9e1774903..79b541950 100644 --- a/src/windows/views/timeline_backend/qwidget/clip.py +++ b/src/windows/views/timeline_backend/qwidget/clip.py @@ -26,8 +26,8 @@ """ import uuid -from PyQt5.QtCore import Qt, QRectF -from PyQt5.QtWidgets import QApplication +from qt_api import Qt, QRectF +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 diff --git a/src/windows/views/timeline_backend/qwidget/effect.py b/src/windows/views/timeline_backend/qwidget/effect.py index 9e60dac68..cadf582f2 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 291dcf275..02d951dae 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 4e7eaf7dd..643c02da0 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..6ff13adb5 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 from classes.app import get_app diff --git a/src/windows/views/timeline_backend/qwidget/thumbnails.py b/src/windows/views/timeline_backend/qwidget/thumbnails.py index fcbe9a824..eb34005a3 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 diff --git a/src/windows/views/timeline_backend/qwidget/track.py b/src/windows/views/timeline_backend/qwidget/track.py index 6660fc51b..ed6013d32 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 +from qt_api import QRectF from classes.app import get_app TRACK_TOOLBAR_SPACING_REDUCTION = 2.0 diff --git a/src/windows/views/timeline_backend/qwidget/transition.py b/src/windows/views/timeline_backend/qwidget/transition.py index eb101b831..e5ef1309d 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 fa97436bb..f21381f23 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..1f4631a33 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 +from qt_api import QColor +from qt_api import QWebEngineView, QWebEnginePage +from qt_api import QWebChannel class LoggingWebEnginePage(QWebEnginePage): 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 47c0e34e7..797993356 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 -from PyQt5.QtWidgets import QListView +from qt_api import QTimer, Qt, QModelIndex +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 fb1ff3ea7..9278e63b8 100644 --- a/src/windows/views/transitions_listview.py +++ b/src/windows/views/transitions_listview.py @@ -25,9 +25,9 @@ along with OpenShot Library. If not, see . """ -from PyQt5.QtCore import Qt, QSize, QPoint, QRegularExpression -from PyQt5.QtGui import QDrag -from PyQt5.QtWidgets import QListView, QAbstractItemView +from qt_api import Qt, QSize, QPoint, QRegularExpression +from qt_api import QDrag +from qt_api import QListView, QAbstractItemView from classes import info from classes.app import get_app diff --git a/src/windows/views/transitions_treeview.py b/src/windows/views/transitions_treeview.py index 3840d37be..5b256cce2 100644 --- a/src/windows/views/transitions_treeview.py +++ b/src/windows/views/transitions_treeview.py @@ -25,9 +25,9 @@ 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 QDrag +from qt_api import QTreeView, QAbstractItemView, QSizePolicy from classes import info from classes.app import get_app diff --git a/src/windows/views/tutorial.py b/src/windows/views/tutorial.py index 679d8ef96..687b73dc3 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, 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, ) diff --git a/src/windows/views/zoom_slider.py b/src/windows/views/zoom_slider.py index 7594509f1..23230008c 100644 --- a/src/windows/views/zoom_slider.py +++ b/src/windows/views/zoom_slider.py @@ -27,13 +27,13 @@ import copy import math -from PyQt5.QtCore import ( +from qt_api import ( Qt, QCoreApplication, QRectF, QTimer, QSize ) -from PyQt5.QtGui import ( +from qt_api import ( QPainter, QColor, QPen, QBrush, QCursor, QPainterPath, QIcon ) -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 92a0e356047c3714adfc70bfafbf9cc6255b6857 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Thu, 18 Dec 2025 18:20:52 -0600 Subject: [PATCH 03/32] Initial PyQt6 support in our qt_api shim - tested in Ubuntu 25.04 VM. Both PyQt5 and PyQt6 are now working, except some cursor issues, which seems to get stuck after a drag/drop item operation. --- src/classes/app.py | 6 + src/launch.py | 5 +- src/qt_api.py | 1053 ++++++++++++++++- src/windows/models/profiles_model.py | 13 +- src/windows/preview_thread.py | 15 +- src/windows/title_editor.py | 7 +- src/windows/ui/preferences.ui | 16 +- src/windows/video_widget.py | 29 +- src/windows/views/effects_listview.py | 7 +- src/windows/views/effects_treeview.py | 7 +- src/windows/views/emojis_listview.py | 7 +- src/windows/views/files_listview.py | 12 +- src/windows/views/files_treeview.py | 12 +- src/windows/views/profiles_treeview.py | 2 +- src/windows/views/properties_tableview.py | 64 +- src/windows/views/timeline.py | 21 +- .../views/timeline_backend/paint/ruler.py | 10 +- .../views/timeline_backend/qwidget/base.py | 64 +- .../views/timeline_backend/qwidget/clip.py | 40 +- .../timeline_backend/qwidget/playhead.py | 11 +- .../views/timeline_backend/qwidget/track.py | 4 + src/windows/views/transitions_listview.py | 7 +- src/windows/views/transitions_treeview.py | 7 +- src/windows/views/zoom_slider.py | 35 +- 24 files changed, 1322 insertions(+), 132 deletions(-) diff --git a/src/classes/app.py b/src/classes/app.py index 36f3dbc34..60de0992b 100644 --- a/src/classes/app.py +++ b/src/classes/app.py @@ -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) diff --git a/src/launch.py b/src/launch.py index 39d458739..ca2c977ec 100755 --- a/src/launch.py +++ b/src/launch.py @@ -237,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 index be8ad3a64..db35bdcb7 100644 --- a/src/qt_api.py +++ b/src/qt_api.py @@ -15,7 +15,7 @@ # Public exports filled in after binding selection -QtCore = QtGui = QtWidgets = QtSvg = QtWebEngineWidgets = QtWebChannel = QtWebKitWidgets = None +QtCore = QtGui = QtWidgets = QtSvg = QtWebEngineCore = QtWebEngineWidgets = QtWebChannel = QtWebKitWidgets = None Signal = Slot = Property = None QRegularExpression = None QState = QStateMachine = None @@ -28,6 +28,87 @@ _SELECTING = False +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) + if QT_API == "pyside2": + try: + import shiboken2 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) + return mod.wrapInstance(int(ptr), 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 _patch_enums_for_qt6(): """Backfill Qt5-style enum attributes on Qt6 scoped enums.""" if QT_API not in ("pyqt6", "pyside6"): @@ -72,6 +153,954 @@ def _patch_enums_for_qt6(): 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 + + 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 + + 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 + + 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 + + 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 + + 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.""" @@ -103,6 +1132,7 @@ def _import_binding(name: str) -> Tuple: 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 @@ -111,6 +1141,7 @@ def _import_binding(name: str) -> Tuple: 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: @@ -121,6 +1152,7 @@ def _import_binding(name: str) -> Tuple: QtGuiMod, QtWidgetsMod, QtSvgMod, + QtWebEngineCoreMod, QtWebEngineWidgetsMod, QtWebChannelMod, QtWebKitWidgetsMod, @@ -152,6 +1184,7 @@ def _import_binding(name: str) -> Tuple: 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 @@ -160,6 +1193,7 @@ def _import_binding(name: str) -> Tuple: 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: @@ -170,6 +1204,7 @@ def _import_binding(name: str) -> Tuple: QtGuiMod, QtWidgetsMod, QtSvgMod, + QtWebEngineCoreMod, QtWebEngineWidgetsMod, QtWebChannelMod, QtWebKitWidgetsMod, @@ -195,6 +1230,7 @@ def _import_binding(name: str) -> Tuple: raise ImportError("PyQt5 missing QState/QStateMachine in QtCore") QtSvgMod = None + QtWebEngineCoreMod = None QtWebEngineWidgetsMod = None QtWebChannelMod = None QtWebKitWidgetsMod = None @@ -203,6 +1239,7 @@ def _import_binding(name: str) -> Tuple: 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: @@ -217,6 +1254,7 @@ def _import_binding(name: str) -> Tuple: QtGuiMod, QtWidgetsMod, QtSvgMod, + QtWebEngineCoreMod, QtWebEngineWidgetsMod, QtWebChannelMod, QtWebKitWidgetsMod, @@ -248,6 +1286,7 @@ def _import_binding(name: str) -> Tuple: raise ImportError("PySide2 QtStateMachine module not available (QState/QStateMachine missing)") QtSvgMod = None + QtWebEngineCoreMod = None QtWebEngineWidgetsMod = None QtWebChannelMod = None QtWebKitWidgetsMod = None @@ -256,6 +1295,7 @@ def _import_binding(name: str) -> Tuple: except Exception: pass try: + import PySide2.QtWebEngineCore as QtWebEngineCoreMod # type: ignore import PySide2.QtWebEngineWidgets as QtWebEngineWidgetsMod # type: ignore import PySide2.QtWebChannel as QtWebChannelMod # type: ignore except Exception: @@ -270,6 +1310,7 @@ def _import_binding(name: str) -> Tuple: QtGuiMod, QtWidgetsMod, QtSvgMod, + QtWebEngineCoreMod, QtWebEngineWidgetsMod, QtWebChannelMod, QtWebKitWidgetsMod, @@ -289,7 +1330,7 @@ def _import_binding(name: str) -> Tuple: def _select_binding() -> str: """Select and load the first available binding.""" - global QtCore, QtGui, QtWidgets, QtSvg, QtWebEngineWidgets, QtWebChannel, QtWebKitWidgets + global QtCore, QtGui, QtWidgets, QtSvg, QtWebEngineCore, QtWebEngineWidgets, QtWebChannel, QtWebKitWidgets global Signal, Slot, Property, QRegularExpression, QState, QStateMachine, uic, QT_API, QT_VERSION_STR, BINDING_VERSION_STR, _MODULES global _FAILED_IMPORT, _SELECTING @@ -313,6 +1354,7 @@ def _select_binding() -> str: QtGui, QtWidgets, QtSvg, + QtWebEngineCore, QtWebEngineWidgets, QtWebChannel, QtWebKitWidgets, @@ -339,6 +1381,7 @@ def _select_binding() -> str: QtGui, QtWidgets, QtSvg, + QtWebEngineCore, QtWebEngineWidgets, QtWebChannel, QtWebKitWidgets, @@ -438,6 +1481,7 @@ def __getattr__(name): "QtGui", "QtWidgets", "QtSvg", + "QtWebEngineCore", "QtWebEngineWidgets", "QtWebChannel", "QtWebKitWidgets", @@ -459,4 +1503,9 @@ def __getattr__(name): "BINDING_VERSION_STR", "ensure_binding", "load_ui", + "unwrapinstance", + "wrapinstance", + "isdeleted", + "modifiers_has", + "clear_override_cursor", ] diff --git a/src/windows/models/profiles_model.py b/src/windows/models/profiles_model.py index 6c8b534e0..88aeefd09 100644 --- a/src/windows/models/profiles_model.py +++ b/src/windows/models/profiles_model.py @@ -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/preview_thread.py b/src/windows/preview_thread.py index 3758ab0ee..1a9e9b970 100644 --- a/src/windows/preview_thread.py +++ b/src/windows/preview_thread.py @@ -27,18 +27,9 @@ import time import math -try: - import sip # PyQt5 -except ImportError: - try: - from PyQt5 import sip # type: ignore - except Exception: - try: - from PyQt6 import sip # type: ignore - except Exception: - sip = None 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 @@ -283,8 +274,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/title_editor.py b/src/windows/title_editor.py index 0bf0b560d..63d7a730f 100644 --- a/src/windows/title_editor.py +++ b/src/windows/title_editor.py @@ -71,8 +71,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) 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 be5293ff5..5a579da2e 100644 --- a/src/windows/video_widget.py +++ b/src/windows/video_widget.py @@ -34,6 +34,8 @@ Qt, QCoreApplication, QMutex, QTimer, QPoint, QPointF, QSize, QSizeF, QRect, QRectF, ) +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 ) @@ -828,6 +830,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) @@ -1148,7 +1157,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 @@ -1353,7 +1362,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 @@ -2028,6 +2037,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", @@ -2038,7 +2058,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 QT_API == "pyqt5" or 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/effects_listview.py b/src/windows/views/effects_listview.py index a77e42fd7..e6c186a09 100644 --- a/src/windows/views/effects_listview.py +++ b/src/windows/views/effects_listview.py @@ -26,6 +26,7 @@ """ from qt_api import QSize, QPoint, Qt, QRegularExpression +from qt_api import clear_override_cursor from qt_api import QDrag from qt_api import QListView, QAbstractItemView @@ -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() diff --git a/src/windows/views/effects_treeview.py b/src/windows/views/effects_treeview.py index e2ebcd8f0..aa6ba568b 100644 --- a/src/windows/views/effects_treeview.py +++ b/src/windows/views/effects_treeview.py @@ -26,6 +26,7 @@ """ 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 @@ -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 4f809178b..2fd0aaf8a 100644 --- a/src/windows/views/emojis_listview.py +++ b/src/windows/views/emojis_listview.py @@ -27,6 +27,7 @@ import os from qt_api import QMimeData, QSize, QPoint, Qt, pyqtSlot, QRegularExpression +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 @@ -74,7 +75,11 @@ def startDrag(self, event): drag.setMimeData(data) # Start drag - 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 add_file(self, filepath): # Add file into project diff --git a/src/windows/views/files_listview.py b/src/windows/views/files_listview.py index 45a4dbc89..6510059b5 100644 --- a/src/windows/views/files_listview.py +++ b/src/windows/views/files_listview.py @@ -27,6 +27,8 @@ """ from qt_api import QSize, Qt, QPoint, QRegularExpression +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 @@ -110,9 +112,9 @@ def contextMenuEvent(self, 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() @@ -178,7 +180,11 @@ def startDrag(self, supportedActions): drag.setHotSpot(composite_pixmap.rect().center()) # Execute the drag operation - drag.exec_(supportedActions) + 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() # Without defining this method, the 'copy' action doesn't show with cursor def dragMoveEvent(self, event): diff --git a/src/windows/views/files_treeview.py b/src/windows/views/files_treeview.py index edf4ea676..84618556c 100644 --- a/src/windows/views/files_treeview.py +++ b/src/windows/views/files_treeview.py @@ -30,6 +30,8 @@ import os 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 @@ -115,9 +117,9 @@ def mouseDoubleClickEvent(self, event): 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() @@ -183,7 +185,11 @@ def startDrag(self, supportedActions): drag.setHotSpot(composite_pixmap.rect().center()) # Execute the drag operation - drag.exec_(supportedActions) + 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() # Without defining this method, the 'copy' action doesn't show with cursor def dragMoveEvent(self, event): diff --git a/src/windows/views/profiles_treeview.py b/src/windows/views/profiles_treeview.py index bc2e8ab62..c9575c80b 100644 --- a/src/windows/views/profiles_treeview.py +++ b/src/windows/views/profiles_treeview.py @@ -60,7 +60,7 @@ def refresh_view(self, filter_text=""): self.is_filter_running = True self.model().setFilterCaseSensitivity(Qt.CaseInsensitive) self.model().setFilterRegularExpression(QRegularExpression(filter_text.lower())) - self.model().sort(Qt.DescendingOrder) + self.model().sort(0, Qt.DescendingOrder) # Format columns self.sortByColumn(0, Qt.DescendingOrder) diff --git a/src/windows/views/properties_tableview.py b/src/windows/views/properties_tableview.py index 6b7f368d2..08153de00 100644 --- a/src/windows/views/properties_tableview.py +++ b/src/windows/views/properties_tableview.py @@ -30,19 +30,9 @@ import functools from operator import itemgetter import uuid -try: - import sip # PyQt5 -except ImportError: - try: - from PyQt5 import sip # type: ignore - except Exception: - try: - from PyQt6 import sip # type: ignore - except Exception: - # Defer failure to later use-sites for clearer traceback - sip = None -from qt_api import Qt, QRectF, QLocale, pyqtSignal, pyqtSlot, QEvent, QPoint +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, @@ -140,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) @@ -161,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) @@ -191,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) @@ -285,7 +286,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) @@ -296,12 +298,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 @@ -315,8 +319,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 @@ -333,7 +337,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 @@ -372,13 +376,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: @@ -433,7 +437,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) @@ -524,8 +529,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 @@ -566,13 +571,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) @@ -1044,8 +1050,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 diff --git a/src/windows/views/timeline.py b/src/windows/views/timeline.py index dad5ff031..849239457 100644 --- a/src/windows/views/timeline.py +++ b/src/windows/views/timeline.py @@ -39,6 +39,7 @@ import openshot 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 @@ -84,7 +85,15 @@ 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/PySide2)." + ) from ex + + +def _event_posf(event): + if hasattr(event, "posF"): + return event.posF() + return event.position() class TimelineView(updates.UpdateInterface, ViewClass): @@ -2444,9 +2453,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: @@ -3628,7 +3637,7 @@ 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"]) @@ -4002,7 +4011,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"]: @@ -4020,7 +4029,7 @@ def dropEvent(self, event): event.accept() if self.item_type == "effect": - pos = event.posF() + pos = _event_posf(event) data = json.loads(event.mimeData().text()) self.addEffect(data, pos) diff --git a/src/windows/views/timeline_backend/paint/ruler.py b/src/windows/views/timeline_backend/paint/ruler.py index 149363130..b140751f5 100644 --- a/src/windows/views/timeline_backend/paint/ruler.py +++ b/src/windows/views/timeline_backend/paint/ruler.py @@ -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/qwidget/base.py b/src/windows/views/timeline_backend/qwidget/base.py index 91f0f9ad4..0ada1c65e 100644 --- a/src/windows/views/timeline_backend/qwidget/base.py +++ b/src/windows/views/timeline_backend/qwidget/base.py @@ -29,6 +29,7 @@ from functools import partial 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 from qt_api import ( @@ -95,6 +96,12 @@ def _collect_signal_signatures(qobject_type): _TIMELINE_EVENT_SIGNATURES = _collect_signal_signatures(TimelineEvents) +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.""" @@ -312,9 +319,21 @@ 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"]: + if QT_API == "pyqt5": + self.cursors[cursor_name] = QCursor(cursor_fallbacks[cursor_name]) + continue 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) @@ -1037,7 +1056,7 @@ def _viewport_offsets(self): return h_offset, v_offset def _event_seconds_track(self, event): - pos = event.pos() + pos = _event_posf(event) if pos.x() < self.track_name_width or pos.y() < self.ruler_height: return None if not self.track_list: @@ -1997,30 +2016,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) @@ -2102,7 +2122,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) @@ -2180,13 +2200,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) @@ -2206,7 +2227,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) @@ -2219,33 +2240,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 @@ -2313,7 +2334,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 @@ -2325,17 +2346,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 79b541950..fa3992059 100644 --- a/src/windows/views/timeline_backend/qwidget/clip.py +++ b/src/windows/views/timeline_backend/qwidget/clip.py @@ -26,7 +26,7 @@ """ import uuid -from qt_api import Qt, QRectF +from qt_api import Qt, QRectF, QPointF from qt_api import QApplication from classes.app import get_app from classes.query import Clip, Transition @@ -206,13 +206,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: @@ -297,11 +297,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): @@ -313,7 +314,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 @@ -323,7 +324,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) @@ -332,7 +333,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 @@ -510,7 +511,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 @@ -592,7 +594,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) @@ -619,7 +621,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: @@ -753,7 +755,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 @@ -766,7 +768,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) @@ -800,7 +802,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": @@ -904,7 +906,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 @@ -914,7 +917,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) if panel_lane: self._panel_box_track = panel_lane.get("track") @@ -930,7 +933,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/playhead.py b/src/windows/views/timeline_backend/qwidget/playhead.py index 6ff13adb5..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 qt_api 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/track.py b/src/windows/views/timeline_backend/qwidget/track.py index ed6013d32..7501be76b 100644 --- a/src/windows/views/timeline_backend/qwidget/track.py +++ b/src/windows/views/timeline_backend/qwidget/track.py @@ -185,6 +185,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): @@ -309,6 +311,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/transitions_listview.py b/src/windows/views/transitions_listview.py index 9278e63b8..6d6fca344 100644 --- a/src/windows/views/transitions_listview.py +++ b/src/windows/views/transitions_listview.py @@ -26,6 +26,7 @@ """ from qt_api import Qt, QSize, QPoint, QRegularExpression +from qt_api import clear_override_cursor from qt_api import QDrag from qt_api import QListView, QAbstractItemView @@ -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() diff --git a/src/windows/views/transitions_treeview.py b/src/windows/views/transitions_treeview.py index 5b256cce2..7ac6142ce 100644 --- a/src/windows/views/transitions_treeview.py +++ b/src/windows/views/transitions_treeview.py @@ -26,6 +26,7 @@ """ 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 @@ -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/zoom_slider.py b/src/windows/views/zoom_slider.py index 23230008c..f02fecb55 100644 --- a/src/windows/views/zoom_slider.py +++ b/src/windows/views/zoom_slider.py @@ -28,8 +28,9 @@ import math from qt_api import ( - Qt, QCoreApplication, QRectF, QTimer, QSize + Qt, QCoreApplication, QRectF, QTimer, QSize, QPointF ) +from qt_api import modifiers_has from qt_api import ( QPainter, QColor, QPen, QBrush, QCursor, QPainterPath, QIcon ) @@ -38,6 +39,12 @@ 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 @@ -264,17 +271,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 +341,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 +353,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 +365,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 +385,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 +408,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 From 6e0a415a65ac86a6036bb5341369896f0f1b42c5 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Thu, 18 Dec 2025 23:32:44 -0600 Subject: [PATCH 04/32] Initial support for PySide6, tested on Ubuntu 25.04 VM. --- src/classes/app.py | 5 + src/classes/ui_util.py | 38 ++-- src/qt_api.py | 107 ++++++++- src/themes/base.py | 12 +- src/windows/cutting.py | 3 +- src/windows/main_window.py | 101 +++++---- src/windows/models/files_model.py | 49 +++- src/windows/title_editor.py | 4 +- src/windows/video_widget.py | 12 +- src/windows/views/effects_listview.py | 4 +- src/windows/views/effects_treeview.py | 2 +- src/windows/views/emojis_listview.py | 8 +- src/windows/views/files_listview.py | 2 +- src/windows/views/files_treeview.py | 2 +- src/windows/views/menu.py | 19 ++ src/windows/views/profiles_treeview.py | 4 +- src/windows/views/properties_tableview.py | 14 +- src/windows/views/timeline.py | 209 ++++++++++++++++-- .../timeline_backend/qwidget/thumbnails.py | 7 +- .../views/timeline_backend/webengine.py | 47 +++- src/windows/views/transitions_listview.py | 4 +- src/windows/views/transitions_treeview.py | 2 +- src/windows/views/zoom_slider.py | 10 +- 23 files changed, 545 insertions(+), 120 deletions(-) diff --git a/src/classes/app.py b/src/classes/app.py index 60de0992b..fccf729f9 100644 --- a/src/classes/app.py +++ b/src/classes/app.py @@ -344,6 +344,11 @@ def _tr(self, message): 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/ui_util.py b/src/classes/ui_util.py index 6eef7864d..6d3eb4dd2 100644 --- a/src/classes/ui_util.py +++ b/src/classes/ui_util.py @@ -40,7 +40,7 @@ from qt_api import QIcon, QPalette, QColor from qt_api import ( QApplication, QWidget, QTabWidget, QAction) -from qt_api 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/qt_api.py b/src/qt_api.py index db35bdcb7..5ee112917 100644 --- a/src/qt_api.py +++ b/src/qt_api.py @@ -22,6 +22,7 @@ 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 @@ -1165,6 +1166,7 @@ def _import_binding(name: str) -> Tuple: uicMod, QtCoreMod.QT_VERSION_STR, QtCoreMod.PYQT_VERSION_STR, + QtCoreMod.PYQT_VERSION_STR, ) if name == "pyside6": @@ -1217,6 +1219,7 @@ def _import_binding(name: str) -> Tuple: QtUiToolsMod, QtCoreMod.__version__, # PySide binds Qt version here QtCoreMod.__version__, + QtCoreMod.__version__, ) if name == "pyqt5": @@ -1267,6 +1270,7 @@ def _import_binding(name: str) -> Tuple: uicMod, QtCoreMod.QT_VERSION_STR, QtCoreMod.PYQT_VERSION_STR, + QtCoreMod.PYQT_VERSION_STR, ) if name == "pyside2": @@ -1323,6 +1327,7 @@ def _import_binding(name: str) -> Tuple: QtUiToolsMod, QtCoreMod.__version__, QtCoreMod.__version__, + QtCoreMod.__version__, ) raise ImportError(f"Unknown binding '{name}'") @@ -1331,7 +1336,7 @@ def _import_binding(name: str) -> Tuple: 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, BINDING_VERSION_STR, _MODULES + 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: @@ -1366,6 +1371,7 @@ def _select_binding() -> str: QStateMachine, uic, QT_VERSION_STR, + PYQT_VERSION_STR, BINDING_VERSION_STR, ) = _import_binding(candidate) logger.info( @@ -1420,12 +1426,106 @@ def load_ui(path: str, baseinstance=None): from importlib import import_module QtUiTools = import_module("PySide6.QtUiTools" if QT_API == "pyside6" else "PySide2.QtUiTools") # type: ignore - loader = QtUiTools.QUiLoader() + 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: - return loader.load(ui_file, baseinstance) + 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() @@ -1500,6 +1600,7 @@ def __getattr__(name): "QLibraryInfo", "QT_API", "QT_VERSION_STR", + "PYQT_VERSION_STR", "BINDING_VERSION_STR", "ensure_binding", "load_ui", diff --git a/src/themes/base.py b/src/themes/base.py index 3d08107fd..bf21f879f 100755 --- a/src/themes/base.py +++ b/src/themes/base.py @@ -132,8 +132,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) @@ -178,6 +184,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) diff --git a/src/windows/cutting.py b/src/windows/cutting.py index 3fc114288..6a610a5c1 100644 --- a/src/windows/cutting.py +++ b/src/windows/cutting.py @@ -202,7 +202,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(): @@ -426,4 +426,3 @@ def closeEvent(self, event): self.r.Close() self.clip.Close() self.r.ClearAllCache() - diff --git a/src/windows/main_window.py b/src/windows/main_window.py index 37ebdf643..03edee821 100644 --- a/src/windows/main_window.py +++ b/src/windows/main_window.py @@ -166,8 +166,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 -----------------') @@ -192,8 +200,10 @@ 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): - 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: @@ -210,9 +220,10 @@ def closeEvent(self, event): if self.preview_thread: 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 @@ -2641,6 +2652,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) @@ -2826,6 +2842,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'): @@ -3039,7 +3056,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.filesActionGroup = QActionGroup(self) self.filesActionGroup.setExclusive(True) self.filesActionGroup.addAction(self.actionFilesShowAll) @@ -3051,15 +3068,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.transitionsActionGroup = QActionGroup(self) self.transitionsActionGroup.setExclusive(True) self.transitionsActionGroup.addAction(self.actionTransitionsShowAll) @@ -3067,16 +3084,16 @@ 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.effectsFilter = QLineEdit() + self.effectsToolbar = QToolBar("Effects Toolbar", self.dockEffectsContents) + self.effectsFilter = QLineEdit(self.effectsToolbar) self.effectsActionGroup = QActionGroup(self) self.effectsActionGroup.setExclusive(True) self.effectsActionGroup.addAction(self.actionEffectsShowAll) @@ -3090,38 +3107,38 @@ 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.emojiFilterGroup = QComboBox() - self.emojisFilter = QLineEdit() + self.emojisToolbar = QToolBar("Emojis Toolbar", self.dockEmojisContents) + 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.tabVideo.layout().addWidget(self.videoToolbar) + self.videoToolbar = QToolBar("Video Toolbar", self.dockVideoContents) + 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) @@ -3145,7 +3162,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""" @@ -3237,7 +3255,6 @@ def showEvent(self, event): if (self.saved_geometry or self.saved_state) and not self._restored_saved_window: # Delay the restore until after the window is shown so layouts are settled. QTimer.singleShot(0, self._restore_saved_window) - def hideEvent(self, event): """ Have any child windows hide with main window """ QMainWindow.hideEvent(self, event) @@ -3342,8 +3359,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() @@ -3359,8 +3376,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() @@ -3376,8 +3393,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() @@ -3392,7 +3409,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") @@ -3726,7 +3743,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 @@ -3924,7 +3945,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) @@ -3976,9 +4000,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/files_model.py b/src/windows/models/files_model.py index 47a2edf58..aef00f08c 100644 --- a/src/windows/models/files_model.py +++ b/src/windows/models/files_model.py @@ -34,7 +34,7 @@ import uuid from qt_api import ( - QMimeData, Qt, pyqtSignal, QEventLoop, QObject, + QMimeData, Qt, QUrl, pyqtSignal, QEventLoop, QObject, QSortFilterProxyModel, QItemSelectionModel, QPersistentModelIndex, QModelIndex ) from qt_api import ( @@ -57,10 +57,13 @@ class FileFilterProxyModel(QSortFilterProxyModel): def filterAcceptsRow(self, sourceRow, sourceParent): """Filter for text""" + from qt_api import isdeleted + 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) @@ -92,23 +95,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 @@ -613,6 +644,7 @@ def value_updated(self, item): f.save() def __init__(self, *args): + super().__init__(*args) # Add self as listener to project data updates # (undo/redo, as well as normal actions handled within this class all update the model) @@ -645,9 +677,6 @@ def __init__(self, *args): app.window.refreshFilesSignal.connect( functools.partial(self.update_model, clear=False)) - # Call init for superclass QObject - super(QObject, FilesModel).__init__(self, *args) - # Attempt to load model testing interface, if requested # (will only succeed with Qt 5.11+) if info.MODEL_TEST: diff --git a/src/windows/title_editor.py b/src/windows/title_editor.py index 63d7a730f..8469dba5b 100644 --- a/src/windows/title_editor.py +++ b/src/windows/title_editor.py @@ -150,7 +150,7 @@ def __init__(self, *args, edit_file_path=None, duplicate=False, **kwargs): self.verticalLayout.addWidget(self.titlesView) # Disable Save button on window load - self.buttonBox.button(self.buttonBox.Save).setEnabled(False) + self.saveButton.setEnabled(False) # Connect thumbnail listener self.thumbnailReady.connect(self.display_pixmap) @@ -476,7 +476,7 @@ def load_svg_template(self, filename_field=None): self.btnFontColor.setEnabled(False) # Enable Save button when a template is selected - self.buttonBox.button(self.buttonBox.Save).setEnabled(True) + self.saveButton.setEnabled(True) def writeToFile(self, xmldoc): '''writes a new svg file containing the user edited data''' diff --git a/src/windows/video_widget.py b/src/windows/video_widget.py index 5a579da2e..3d5984cf4 100644 --- a/src/windows/video_widget.py +++ b/src/windows/video_widget.py @@ -32,7 +32,7 @@ from qt_api import ( Qt, QCoreApplication, QMutex, QTimer, - QPoint, QPointF, QSize, QSizeF, QRect, QRectF, + QPoint, QPointF, QSize, QSizeF, QRect, QRectF, QLineF, ) from qt_api import QT_API from qt_api import modifiers_has @@ -355,7 +355,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() @@ -638,7 +641,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: diff --git a/src/windows/views/effects_listview.py b/src/windows/views/effects_listview.py index e6c186a09..c4332b815 100644 --- a/src/windows/views/effects_listview.py +++ b/src/windows/views/effects_listview.py @@ -48,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 """ @@ -88,7 +88,7 @@ def refresh_view(self): filter_text = self.win.effectsFilter.text() self.model().setFilterRegularExpression(QRegularExpression(filter_text.replace(' ', '.*'))) self.model().setFilterCaseSensitivity(Qt.CaseInsensitive) - self.model().sort(Qt.AscendingOrder) + self.model().sort(0, Qt.AscendingOrder) def __init__(self, model): # Invoke parent init diff --git a/src/windows/views/effects_treeview.py b/src/windows/views/effects_treeview.py index aa6ba568b..ff7fd4478 100644 --- a/src/windows/views/effects_treeview.py +++ b/src/windows/views/effects_treeview.py @@ -49,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 """ diff --git a/src/windows/views/emojis_listview.py b/src/windows/views/emojis_listview.py index 2fd0aaf8a..5cb30ce58 100644 --- a/src/windows/views/emojis_listview.py +++ b/src/windows/views/emojis_listview.py @@ -26,7 +26,7 @@ """ import os -from qt_api import QMimeData, QSize, QPoint, Qt, pyqtSlot, QRegularExpression +from qt_api import QMimeData, QSize, QPoint, Qt, QUrl, pyqtSlot, QRegularExpression 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) @@ -72,6 +72,12 @@ def startDrag(self, event): 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 diff --git a/src/windows/views/files_listview.py b/src/windows/views/files_listview.py index 6510059b5..ea2eb41a2 100644 --- a/src/windows/views/files_listview.py +++ b/src/windows/views/files_listview.py @@ -107,7 +107,7 @@ 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) diff --git a/src/windows/views/files_treeview.py b/src/windows/views/files_treeview.py index 84618556c..6e48e218b 100644 --- a/src/windows/views/files_treeview.py +++ b/src/windows/views/files_treeview.py @@ -110,7 +110,7 @@ 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 diff --git a/src/windows/views/menu.py b/src/windows/views/menu.py index 2d6f62772..3145cd87d 100644 --- a/src/windows/views/menu.py +++ b/src/windows/views/menu.py @@ -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 c9575c80b..c04d92c6a 100644 --- a/src/windows/views/profiles_treeview.py +++ b/src/windows/views/profiles_treeview.py @@ -120,11 +120,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 08153de00..292d31371 100644 --- a/src/windows/views/properties_tableview.py +++ b/src/windows/views/properties_tableview.py @@ -957,7 +957,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) def build_menu(self, data, parent_menu=None): """Build a Context Menu, included nested sub-menus, and divide lists if too large""" @@ -1355,21 +1355,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/timeline.py b/src/windows/views/timeline.py index 849239457..c374d522a 100644 --- a/src/windows/views/timeline.py +++ b/src/windows/views/timeline.py @@ -89,6 +89,8 @@ "Need QtWebEngine for the active Qt binding (PyQt6/PyQt5 or PySide6/PySide2)." ) from ex +log.info("Timeline backend: %s (%s)", info.WEB_BACKEND, getattr(ViewClass, "__name__", "unknown")) + def _event_posf(event): if hasattr(event, "posF"): @@ -792,7 +794,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): @@ -819,7 +821,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): @@ -905,7 +907,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): @@ -1406,7 +1408,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") @@ -3307,7 +3309,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): @@ -3382,7 +3384,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): @@ -3396,7 +3398,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): @@ -3623,6 +3625,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: @@ -3643,8 +3727,12 @@ def dragEnterEvent(self, event): 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() @@ -3662,14 +3750,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 @@ -3686,8 +3766,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) @@ -3713,8 +3801,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() @@ -3724,6 +3817,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 @@ -4028,9 +4122,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(event) - data = json.loads(event.mimeData().text()) + data = self._mime_json_list(event.mimeData()) self.addEffect(data, pos) elif self.item_type in ["clip", "transition"] and self.item_ids: @@ -4039,10 +4200,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""" @@ -4109,6 +4267,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: @@ -4129,9 +4289,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/qwidget/thumbnails.py b/src/windows/views/timeline_backend/qwidget/thumbnails.py index eb34005a3..4b1caaf9e 100644 --- a/src/windows/views/timeline_backend/qwidget/thumbnails.py +++ b/src/windows/views/timeline_backend/qwidget/thumbnails.py @@ -84,7 +84,7 @@ class TimelineThumbnailManager(QObject): def __init__(self, parent=None): super().__init__(parent) - self._thread = QThread(self) + self._thread = QThread() self._worker = _ThumbnailWorker() self._worker.moveToThread(self._thread) self._request_job.connect(self._worker.request_thumbnail) @@ -106,7 +106,12 @@ def clear_pending(self): def shutdown(self): """Stop the worker thread.""" + if self._thread is None: + return self._clear_jobs.emit() if self._thread.isRunning(): self._thread.quit() self._thread.wait(2000) + self._worker.deleteLater() + self._thread.deleteLater() + self._thread = None diff --git a/src/windows/views/timeline_backend/webengine.py b/src/windows/views/timeline_backend/webengine.py index 1f4631a33..7ec5e1ed2 100644 --- a/src/windows/views/timeline_backend/webengine.py +++ b/src/windows/views/timeline_backend/webengine.py @@ -33,7 +33,7 @@ from classes import info from classes.logger import log -from qt_api import QFileInfo, QUrl, Qt, QTimer +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 @@ -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/transitions_listview.py b/src/windows/views/transitions_listview.py index 6d6fca344..5e0a1ea76 100644 --- a/src/windows/views/transitions_listview.py +++ b/src/windows/views/transitions_listview.py @@ -50,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 """ @@ -89,7 +89,7 @@ def refresh_view(self): filter_text = self.win.transitionsFilter.text() self.model().setFilterRegularExpression(QRegularExpression(filter_text.replace(' ', '.*'))) self.model().setFilterCaseSensitivity(Qt.CaseInsensitive) - self.model().sort(Qt.AscendingOrder) + self.model().sort(0, Qt.AscendingOrder) def __init__(self, model): # Invoke parent init diff --git a/src/windows/views/transitions_treeview.py b/src/windows/views/transitions_treeview.py index 7ac6142ce..6a0320f88 100644 --- a/src/windows/views/transitions_treeview.py +++ b/src/windows/views/transitions_treeview.py @@ -48,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 """ diff --git a/src/windows/views/zoom_slider.py b/src/windows/views/zoom_slider.py index f02fecb55..02a8451fe 100644 --- a/src/windows/views/zoom_slider.py +++ b/src/windows/views/zoom_slider.py @@ -32,7 +32,7 @@ ) from qt_api import modifiers_has from qt_api import ( - QPainter, QColor, QPen, QBrush, QCursor, QPainterPath, QIcon + QPainter, QColor, QPen, QBrush, QCursor, QPainterPath, QIcon, QPalette ) from qt_api import QSizePolicy, QWidget @@ -63,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 @@ -143,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 From 7d76d4a24ab060d461a27a2b26fb40309c874cce Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Thu, 18 Dec 2025 23:38:46 -0600 Subject: [PATCH 05/32] Removing mentions of pyside2 (never supported) --- src/qt_api.py | 77 ++++------------------------------- src/windows/views/timeline.py | 2 +- 2 files changed, 9 insertions(+), 70 deletions(-) diff --git a/src/qt_api.py b/src/qt_api.py index 5ee112917..7a84a7fd5 100644 --- a/src/qt_api.py +++ b/src/qt_api.py @@ -1,9 +1,9 @@ """ Centralized Qt binding loader for OpenShot. -Selects an available binding (PyQt6/PySide6/PyQt5/PySide2) using the +Selects an available binding (PyQt6/PySide6/PyQt5) using the `OPENSHOT_QT_API` env var (`auto` default, otherwise one of -`pyqt6|pyside6|pyqt5|pyside2`). Logs the selection attempts, failures, +`pyqt6|pyside6|pyqt5`). Logs the selection attempts, failures, and final choice to help diagnose environment issues. """ @@ -51,12 +51,6 @@ def _load_sip_like(): except Exception: shiboken_mod = None return ("shiboken", shiboken_mod) - if QT_API == "pyside2": - try: - import shiboken2 as shiboken_mod # type: ignore - except Exception: - shiboken_mod = None - return ("shiboken", shiboken_mod) return ("sip", None) @@ -1106,9 +1100,9 @@ def _exec_wrapper(self, *args, **kwargs): 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", "pyside2"): + if value in ("pyqt6", "pyside6", "pyqt5"): return [value] - return ["pyqt6", "pyside6", "pyqt5", "pyside2"] + return ["pyqt6", "pyside6", "pyqt5"] def _import_binding(name: str) -> Tuple: @@ -1273,63 +1267,6 @@ def _import_binding(name: str) -> Tuple: QtCoreMod.PYQT_VERSION_STR, ) - if name == "pyside2": - import PySide2.QtCore as QtCoreMod - import PySide2.QtGui as QtGuiMod - import PySide2.QtWidgets as QtWidgetsMod - QtUiToolsMod = None - try: - import PySide2.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("PySide2 QtStateMachine module not available (QState/QStateMachine missing)") - - QtSvgMod = None - QtWebEngineCoreMod = None - QtWebEngineWidgetsMod = None - QtWebChannelMod = None - QtWebKitWidgetsMod = None - try: - import PySide2.QtSvg as QtSvgMod # type: ignore - except Exception: - pass - try: - import PySide2.QtWebEngineCore as QtWebEngineCoreMod # type: ignore - import PySide2.QtWebEngineWidgets as QtWebEngineWidgetsMod # type: ignore - import PySide2.QtWebChannel as QtWebChannelMod # type: ignore - except Exception: - pass - try: - import PySide2.QtWebKitWidgets as QtWebKitWidgetsMod # type: ignore - except Exception: - pass - return ( - "pyside2", - QtCoreMod, - QtGuiMod, - QtWidgetsMod, - QtSvgMod, - QtWebEngineCoreMod, - QtWebEngineWidgetsMod, - QtWebChannelMod, - QtWebKitWidgetsMod, - QtCoreMod.Signal, - QtCoreMod.Slot, - QtCoreMod.Property, - QtCoreMod.QRegularExpression, - q_state, - q_state_machine, - QtUiToolsMod, - QtCoreMod.__version__, - QtCoreMod.__version__, - QtCoreMod.__version__, - ) - raise ImportError(f"Unknown binding '{name}'") @@ -1425,7 +1362,9 @@ def load_ui(path: str, baseinstance=None): # PySide from importlib import import_module - QtUiTools = import_module("PySide6.QtUiTools" if QT_API == "pyside6" else "PySide2.QtUiTools") # type: ignore + 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): @@ -1561,7 +1500,7 @@ def __getattr__(name): elif QT_API == "pyqt5": QtStateMachine = QtCore else: - import PySide2.QtStateMachine as QtStateMachine # type: ignore + QtStateMachine = QtCore QState = getattr(QtStateMachine, "QState", None) QStateMachine = getattr(QtStateMachine, "QStateMachine", None) except Exception: diff --git a/src/windows/views/timeline.py b/src/windows/views/timeline.py index c374d522a..04afdb1a2 100644 --- a/src/windows/views/timeline.py +++ b/src/windows/views/timeline.py @@ -86,7 +86,7 @@ finally: if not ViewClass: raise RuntimeError( - "Need QtWebEngine for the active Qt binding (PyQt6/PyQt5 or PySide6/PySide2)." + "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")) From 94c40ab0071510d6a1503baf6cdf4fd5a7c1dd3a Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Thu, 18 Dec 2025 23:54:14 -0600 Subject: [PATCH 06/32] Updating develoers docs with PyQt5, PyQt6, and PySide6 packages and info. --- doc/developers.rst | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) 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! From 1fd295ea683a1337c659a674eb9c3b4a0d2cf855 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Mon, 2 Feb 2026 13:06:39 -0600 Subject: [PATCH 07/32] Fixing merge issue, after merging develop back into this branch --- src/windows/models/transition_model.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/windows/models/transition_model.py b/src/windows/models/transition_model.py index 1cc08c117..ffc574c79 100644 --- a/src/windows/models/transition_model.py +++ b/src/windows/models/transition_model.py @@ -29,9 +29,10 @@ from qt_api import ( QObject, QMimeData, Qt, pyqtSignal, QLocale, - QSortFilterProxyModel, QPersistentModelIndex, QItemSelectionModel, + QSortFilterProxyModel, QPersistentModelIndex, QItemSelectionModel, QItemSelection, QModelIndex, ) 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 From ece2c809a60f116fba90547702956c508e0168c5 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Mon, 2 Feb 2026 13:43:24 -0600 Subject: [PATCH 08/32] Fixing many new PyQt5/PyQt6 issues after merging in develop branch (this might take a few more tries) --- src/qt_api.py | 83 +++++++++++++++++++ src/windows/models/emoji_model.py | 23 ++++- src/windows/models/files_model.py | 11 +-- src/windows/video_widget.py | 2 +- src/windows/views/effects_listview.py | 11 ++- src/windows/views/emojis_listview.py | 11 +-- src/windows/views/files_listview.py | 7 +- .../views/timeline_backend/qwidget/base.py | 3 - src/windows/views/transitions_listview.py | 11 ++- 9 files changed, 135 insertions(+), 27 deletions(-) diff --git a/src/qt_api.py b/src/qt_api.py index 7a84a7fd5..48f18f107 100644 --- a/src/qt_api.py +++ b/src/qt_api.py @@ -104,6 +104,72 @@ def clear_override_cursor(): 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"): @@ -682,6 +748,23 @@ def _patch_enums_for_qt6(): 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) diff --git a/src/windows/models/emoji_model.py b/src/windows/models/emoji_model.py index 704ff6223..97c3d75f6 100644 --- a/src/windows/models/emoji_model.py +++ b/src/windows/models/emoji_model.py @@ -27,7 +27,7 @@ import os -from qt_api import QMimeData, Qt, QSortFilterProxyModel, QModelIndex +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) @@ -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,6 +217,7 @@ 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+) diff --git a/src/windows/models/files_model.py b/src/windows/models/files_model.py index 98c4d3bfa..bcc99efe3 100644 --- a/src/windows/models/files_model.py +++ b/src/windows/models/files_model.py @@ -87,7 +87,7 @@ def data(self, index, role=Qt.DisplayRole): def filterAcceptsRow(self, sourceRow, sourceParent): """Filter for text""" - from qt_api import isdeleted + 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() \ @@ -112,10 +112,11 @@ def filterAcceptsRow(self, sourceRow, sourceParent): ]): return False - # Match against regex pattern (Qt6-compatible) - regex = self.filterRegularExpression() - if regex.pattern(): - return regex.match(file_name).hasMatch() or regex.match(tags).hasMatch() + # Match against regex pattern + 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 diff --git a/src/windows/video_widget.py b/src/windows/video_widget.py index 3d5984cf4..d1bf7a4d1 100644 --- a/src/windows/video_widget.py +++ b/src/windows/video_widget.py @@ -2065,7 +2065,7 @@ def __init__(self, watch_project=True, *args): "hand"]: icon = QIcon(":/cursors/cursor_%s.png" % cursor_name) pixmap = icon.pixmap(32, 32) - if QT_API == "pyqt5" or pixmap.isNull() or pixmap.size().isEmpty(): + if pixmap.isNull() or pixmap.size().isEmpty(): pixmap = None self.cursors[cursor_name] = (pixmap, cursor_fallbacks[cursor_name]) diff --git a/src/windows/views/effects_listview.py b/src/windows/views/effects_listview.py index 346710c3a..e6acae331 100644 --- a/src/windows/views/effects_listview.py +++ b/src/windows/views/effects_listview.py @@ -25,7 +25,7 @@ along with OpenShot Library. If not, see . """ -from qt_api import QSize, QPoint, Qt, QRegularExpression +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 @@ -86,9 +86,12 @@ def filter_changed(self): def refresh_view(self): """Filter transitions with proxy class""" filter_text = self.win.effectsFilter.text() - self.model().setFilterRegularExpression(QRegularExpression(filter_text.replace(' ', '.*'))) - self.model().setFilterCaseSensitivity(Qt.CaseInsensitive) - self.model().sort(0, Qt.AscendingOrder) + 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(0, Qt.AscendingOrder) def __init__(self, model): # Invoke parent init diff --git a/src/windows/views/emojis_listview.py b/src/windows/views/emojis_listview.py index 71278d7d5..52670457d 100644 --- a/src/windows/views/emojis_listview.py +++ b/src/windows/views/emojis_listview.py @@ -125,11 +125,11 @@ def add_file(self, filepath): def filter_changed(self, text): - self.model.set_text_filter(text) + self.emojis_model.set_text_filter(text) def group_changed(self, index): group_id = self.win.emojiFilterGroup.itemData(index) - self.model.set_group_filter(group_id or "") + self.emojis_model.set_group_filter(group_id or "") def refresh_view(self): """Filter emojis with proxy class""" @@ -164,7 +164,8 @@ def __init__(self, model, *args): self.win = get_app().window # Set model (expects a proxy model) - self.model = model + self.emojis_model = model + self.model = self.emojis_model.proxy_model self.setModel(self.model) # Configure selection behavior @@ -185,12 +186,12 @@ def __init__(self, model, *args): self.setWordWrap(False) self.setTextElideMode(Qt.ElideRight) - self.model.ModelRefreshed.connect(self.refresh_view) + self.emojis_model.ModelRefreshed.connect(self.refresh_view) # Activate filter and group selection _ = get_app()._tr self.win.emojisFilter.textChanged.connect(self.filter_changed) self.win.emojiFilterGroup.clear() self.win.emojiFilterGroup.addItem(_("All"), "") - for name, group_id in sorted(self.model.emoji_groups, key=lambda g: g[0]): + for name, group_id in sorted(self.emojis_model.emoji_groups, key=lambda g: g[0]): self.win.emojiFilterGroup.addItem(name, group_id) self.win.emojiFilterGroup.currentIndexChanged.connect(self.group_changed) diff --git a/src/windows/views/files_listview.py b/src/windows/views/files_listview.py index fedd6c713..0743c22e6 100644 --- a/src/windows/views/files_listview.py +++ b/src/windows/views/files_listview.py @@ -26,7 +26,7 @@ along with OpenShot Library. If not, see . """ -from qt_api import QSize, Qt, QPoint, QRegularExpression +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 @@ -221,7 +221,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(QRegularExpression(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/timeline_backend/qwidget/base.py b/src/windows/views/timeline_backend/qwidget/base.py index 0ada1c65e..e8c8230cd 100644 --- a/src/windows/views/timeline_backend/qwidget/base.py +++ b/src/windows/views/timeline_backend/qwidget/base.py @@ -325,9 +325,6 @@ def __init__(self, parent=None): "hand": Qt.OpenHandCursor, } for cursor_name in ["move", "resize_x", "hand"]: - if QT_API == "pyqt5": - self.cursors[cursor_name] = QCursor(cursor_fallbacks[cursor_name]) - continue icon = QIcon(":/cursors/cursor_%s.png" % cursor_name) pixmap = icon.pixmap(24, 24) if pixmap.isNull() or pixmap.size().isEmpty(): diff --git a/src/windows/views/transitions_listview.py b/src/windows/views/transitions_listview.py index db46dc6b2..dc46ae16c 100644 --- a/src/windows/views/transitions_listview.py +++ b/src/windows/views/transitions_listview.py @@ -25,7 +25,7 @@ along with OpenShot Library. If not, see . """ -from qt_api import Qt, QSize, QPoint, QRegularExpression +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 @@ -87,9 +87,12 @@ def filter_changed(self): def refresh_view(self): """Filter transitions with proxy class""" filter_text = self.win.transitionsFilter.text() - self.model().setFilterRegularExpression(QRegularExpression(filter_text.replace(' ', '.*'))) - self.model().setFilterCaseSensitivity(Qt.CaseInsensitive) - self.model().sort(0, Qt.AscendingOrder) + 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(0, Qt.AscendingOrder) def __init__(self, model): # Invoke parent init From 785d46e6f765b537dd07b6213cdf4e4976d3010c Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Mon, 2 Feb 2026 13:56:23 -0600 Subject: [PATCH 09/32] Fixing a few missing PyQt6 imports in qt_api --- src/qt_api.py | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/src/qt_api.py b/src/qt_api.py index 48f18f107..007f27250 100644 --- a/src/qt_api.py +++ b/src/qt_api.py @@ -264,6 +264,13 @@ def _patch_enums_for_qt6(): 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: @@ -586,6 +593,89 @@ def _patch_enums_for_qt6(): 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"): From 57b7ddcdab5fa558ccd070a3359b3bac2d28241e Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Mon, 2 Feb 2026 14:03:06 -0600 Subject: [PATCH 10/32] Fixing some regex issues with PyQt6, preventing filtering from working on a few models (transitions, effects, files) --- src/qt_api.py | 9 +++++++++ src/windows/main_window.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/qt_api.py b/src/qt_api.py index 007f27250..17fc3beab 100644 --- a/src/qt_api.py +++ b/src/qt_api.py @@ -1214,6 +1214,15 @@ def _filterRegExp(self): 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: diff --git a/src/windows/main_window.py b/src/windows/main_window.py index 17712da54..fe4bdbb35 100644 --- a/src/windows/main_window.py +++ b/src/windows/main_window.py @@ -3763,7 +3763,7 @@ def eventFilter(self, obj, event): try: sequences = get_app().window.getShortcutByName(action_name) for sequence in sequences: - if (sequence == QKeySequence(event.modifiers() | event.key())): + if (sequence == QKeySequence(int(event.modifiers()) | int(event.key()))): event.accept() return True except KeyError: From 5c26290005a4eb0b49b3ce18b158e8e3c51f57e6 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Mon, 2 Feb 2026 15:14:30 -0600 Subject: [PATCH 11/32] Fixing a few minor PyQt6 errors and attempting to fix a minor focus/tab order issue. --- src/qt_api.py | 14 ++++++++++++++ src/windows/main_window.py | 8 +++++++- src/windows/preferences.py | 6 +++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/qt_api.py b/src/qt_api.py index 17fc3beab..8a7ac743e 100644 --- a/src/qt_api.py +++ b/src/qt_api.py @@ -233,6 +233,20 @@ def _patch_enums_for_qt6(): 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 diff --git a/src/windows/main_window.py b/src/windows/main_window.py index fe4bdbb35..78d887d4b 100644 --- a/src/windows/main_window.py +++ b/src/windows/main_window.py @@ -3763,7 +3763,13 @@ def eventFilter(self, obj, event): try: sequences = get_app().window.getShortcutByName(action_name) for sequence in sequences: - if (sequence == QKeySequence(int(event.modifiers()) | int(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: diff --git a/src/windows/preferences.py b/src/windows/preferences.py index 197b0b7f8..ab9c3a8d6 100644 --- a/src/windows/preferences.py +++ b/src/windows/preferences.py @@ -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 From 2df764e463019338d34a71bb1ead9ed911255225 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Mon, 2 Feb 2026 15:22:55 -0600 Subject: [PATCH 12/32] Fixing PyQt5 hard-coded import in focus tabstops code, which breaks PyQt6. --- src/classes/tabstops.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/classes/tabstops.py b/src/classes/tabstops.py index 5c5330d60..d3b3de738 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, From 46b273f3c348b7000a84da508055daa31454aacd Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Mon, 2 Feb 2026 15:26:46 -0600 Subject: [PATCH 13/32] Replacing more hard-coded PyQt5 imports with qt_api shims. --- src/classes/info.py | 8 ++++---- src/qt_api.py | 14 ++++++++++++++ src/windows/animated_title.py | 3 +-- src/windows/models/emoji_model.py | 2 +- src/windows/views/properties_tableview.py | 2 +- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/classes/info.py b/src/classes/info.py index 808a039fc..02c9ab49d 100644 --- a/src/classes/info.py +++ b/src/classes/info.py @@ -93,8 +93,8 @@ 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", @@ -158,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/qt_api.py b/src/qt_api.py index 8a7ac743e..d7640c0bf 100644 --- a/src/qt_api.py +++ b/src/qt_api.py @@ -1702,6 +1702,20 @@ def __getattr__(name): 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) diff --git a/src/windows/animated_title.py b/src/windows/animated_title.py index a2ee7d515..15f58d2e9 100644 --- a/src/windows/animated_title.py +++ b/src/windows/animated_title.py @@ -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) diff --git a/src/windows/models/emoji_model.py b/src/windows/models/emoji_model.py index 97c3d75f6..2ceb63655 100644 --- a/src/windows/models/emoji_model.py +++ b/src/windows/models/emoji_model.py @@ -224,7 +224,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.group_model, self.model]: self.model_tests.append( diff --git a/src/windows/views/properties_tableview.py b/src/windows/views/properties_tableview.py index 6dfb0eab7..7b548ffe2 100644 --- a/src/windows/views/properties_tableview.py +++ b/src/windows/views/properties_tableview.py @@ -227,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) From 39d76b3fdbc7d5b58da707ee9a80f4ede7157d11 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Mon, 2 Feb 2026 15:35:25 -0600 Subject: [PATCH 14/32] Replaced all explicit Qt base-class __init__ calls with super().__init__() across the codebase. --- src/windows/about.py | 8 ++++---- src/windows/add_to_timeline.py | 2 +- src/windows/animation.py | 2 +- src/windows/cutting.py | 2 +- src/windows/file_properties.py | 2 +- src/windows/models/files_model.py | 2 +- src/windows/preferences.py | 2 +- src/windows/process_effect.py | 2 +- src/windows/profile.py | 2 +- src/windows/profile_edit.py | 2 +- src/windows/region.py | 5 +---- src/windows/video_widget.py | 2 +- src/windows/views/zoom_slider.py | 2 +- 13 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/windows/about.py b/src/windows/about.py index 38f0cd263..b4eeb473a 100644 --- a/src/windows/about.py +++ b/src/windows/about.py @@ -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 0deb09181..4d2c929b9 100644 --- a/src/windows/add_to_timeline.py +++ b/src/windows/add_to_timeline.py @@ -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/animation.py b/src/windows/animation.py index 481396551..4cac27bf7 100644 --- a/src/windows/animation.py +++ b/src/windows/animation.py @@ -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/cutting.py b/src/windows/cutting.py index 6a610a5c1..de643703c 100644 --- a/src/windows/cutting.py +++ b/src/windows/cutting.py @@ -62,7 +62,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) diff --git a/src/windows/file_properties.py b/src/windows/file_properties.py index 6af87d4dd..76de807ef 100644 --- a/src/windows/file_properties.py +++ b/src/windows/file_properties.py @@ -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/models/files_model.py b/src/windows/models/files_model.py index bcc99efe3..fd0f5a3ae 100644 --- a/src/windows/models/files_model.py +++ b/src/windows/models/files_model.py @@ -762,7 +762,7 @@ 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+) diff --git a/src/windows/preferences.py b/src/windows/preferences.py index ab9c3a8d6..d8e3311e4 100644 --- a/src/windows/preferences.py +++ b/src/windows/preferences.py @@ -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) diff --git a/src/windows/process_effect.py b/src/windows/process_effect.py index 2e5610dad..c92d941e4 100644 --- a/src/windows/process_effect.py +++ b/src/windows/process_effect.py @@ -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 30b1cf0f6..d690ee4a0 100644 --- a/src/windows/profile.py +++ b/src/windows/profile.py @@ -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 01c38eefe..63e134ba8 100644 --- a/src/windows/profile_edit.py +++ b/src/windows/profile_edit.py @@ -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 7629c456f..24eea48f5 100644 --- a/src/windows/region.py +++ b/src/windows/region.py @@ -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/video_widget.py b/src/windows/video_widget.py index d1bf7a4d1..f22f24f95 100644 --- a/src/windows/video_widget.py +++ b/src/windows/video_widget.py @@ -1975,7 +1975,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 diff --git a/src/windows/views/zoom_slider.py b/src/windows/views/zoom_slider.py index 02a8451fe..bec625d00 100644 --- a/src/windows/views/zoom_slider.py +++ b/src/windows/views/zoom_slider.py @@ -602,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 From 60cebe8f4ceed5f089f6b989c0f82f9c7fc20fdd Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Mon, 2 Feb 2026 16:33:35 -0600 Subject: [PATCH 15/32] Fixing a Preferences error for PySide6 - causing the window to fail to appear. --- src/windows/preferences.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/windows/preferences.py b/src/windows/preferences.py index d8e3311e4..6f22d9cd0 100644 --- a/src/windows/preferences.py +++ b/src/windows/preferences.py @@ -33,7 +33,7 @@ from qt_api import Qt, QSize, QDir from qt_api import ( - QWidget, QDialog, QMessageBox, QFileDialog, + QWidget, QDialog, QMessageBox, QFileDialog, QDialogButtonBox, QVBoxLayout, QHBoxLayout, QSizePolicy, QScrollArea, QLabel, QLineEdit, QPushButton, QDoubleSpinBox, QComboBox, QCheckBox, QSpinBox, QStyle, @@ -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) From 9b9ec2979a4c43dfefdab2ff6abe8dc6053daaa7 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Mon, 2 Feb 2026 17:28:44 -0600 Subject: [PATCH 16/32] Fixing title bar height and margins for PySide6 compatibility (and for a consistent look on all widget libraries). --- src/classes/title_bar.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/classes/title_bar.py b/src/classes/title_bar.py index cb6c23995..8c3928c1a 100644 --- a/src/classes/title_bar.py +++ b/src/classes/title_bar.py @@ -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): From af87185ec6107f43daf54b74135e48d3d9b8e98c Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Mon, 2 Feb 2026 17:44:28 -0600 Subject: [PATCH 17/32] Fixing tutorial dialog for better support for PyQt6 & PySide6 (importing QPointF correctly, ending QPainter correctly) --- src/windows/views/tutorial.py | 117 ++++++++++++++++++---------------- 1 file changed, 61 insertions(+), 56 deletions(-) diff --git a/src/windows/views/tutorial.py b/src/windows/views/tutorial.py index 687b73dc3..0a46a385f 100644 --- a/src/windows/views/tutorial.py +++ b/src/windows/views/tutorial.py @@ -27,7 +27,7 @@ import functools -from qt_api import Qt, QPoint, QRectF, QTimer, QObject, QRect +from qt_api import Qt, QPoint, QPointF, QRectF, QTimer, QObject, QRect from qt_api import ( QColor, QPalette, QPen, QPainter, QPainterPath, QKeySequence, ) @@ -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""" From e304c85c5d0a9c41292a45b4981b4c2ae15e7ac1 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Mon, 2 Feb 2026 17:47:33 -0600 Subject: [PATCH 18/32] Another fix to Tutorial dialog to use associatedWidgets for PyQt6 & PySide6 compatibility --- src/windows/views/tutorial.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/windows/views/tutorial.py b/src/windows/views/tutorial.py index 0a46a385f..a9558123f 100644 --- a/src/windows/views/tutorial.py +++ b/src/windows/views/tutorial.py @@ -280,6 +280,16 @@ def process(self): 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": @@ -298,16 +308,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): From 04ac0eeaef09e881bd5f41390c07e8711e50a17f Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Mon, 2 Feb 2026 18:22:59 -0600 Subject: [PATCH 19/32] Improving Tutorial dialog to be compatible with PyQt6/PySide6 --- src/windows/views/tutorial.py | 71 ++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/src/windows/views/tutorial.py b/src/windows/views/tutorial.py index a9558123f..478db213b 100644 --- a/src/windows/views/tutorial.py +++ b/src/windows/views/tutorial.py @@ -141,11 +141,14 @@ 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) + self.setAttribute(Qt.WA_ShowWithoutActivating, True) + if hasattr(Qt, "WA_AlwaysStackOnTop"): + self.setAttribute(Qt.WA_AlwaysStackOnTop, True) # get translations app = get_app() @@ -233,7 +236,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 @@ -254,29 +256,21 @@ 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 @@ -356,8 +350,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): @@ -378,14 +372,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. """ @@ -404,17 +398,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 @@ -422,6 +417,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 @@ -432,7 +432,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): @@ -448,7 +456,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() @@ -531,6 +538,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) From 2c0340fe6fb15f8fb7a01b95ec26a591ddcc4629 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Mon, 2 Feb 2026 18:24:54 -0600 Subject: [PATCH 20/32] Fixing regression in PyQt6 tutorial --- src/windows/views/tutorial.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/windows/views/tutorial.py b/src/windows/views/tutorial.py index 478db213b..9a6b47b58 100644 --- a/src/windows/views/tutorial.py +++ b/src/windows/views/tutorial.py @@ -146,7 +146,8 @@ def __init__(self, widget_id, text, arrow, manager, *args): self.setAttribute(Qt.WA_NoSystemBackground, True) self.setAttribute(Qt.WA_TranslucentBackground, True) self.setAttribute(Qt.WA_DeleteOnClose, True) - self.setAttribute(Qt.WA_ShowWithoutActivating, True) + if hasattr(Qt, "WA_ShowWithoutActivating"): + self.setAttribute(Qt.WA_ShowWithoutActivating, True) if hasattr(Qt, "WA_AlwaysStackOnTop"): self.setAttribute(Qt.WA_AlwaysStackOnTop, True) From ae4c3de0189f544ee1f8b577074bc117bbb8d837 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 3 Feb 2026 13:35:35 -0600 Subject: [PATCH 21/32] Removing selection rectangles from Transitions, Effects, and Emojis (Thumbnail view) - since they are single selection only lists --- src/windows/views/effects_listview.py | 2 ++ src/windows/views/emojis_listview.py | 4 +++- src/windows/views/transitions_listview.py | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/windows/views/effects_listview.py b/src/windows/views/effects_listview.py index e6acae331..b11a8f9b5 100644 --- a/src/windows/views/effects_listview.py +++ b/src/windows/views/effects_listview.py @@ -115,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/emojis_listview.py b/src/windows/views/emojis_listview.py index 52670457d..f30dcb1a1 100644 --- a/src/windows/views/emojis_listview.py +++ b/src/windows/views/emojis_listview.py @@ -169,8 +169,10 @@ def __init__(self, model, *args): self.setModel(self.model) # Configure selection behavior - self.setSelectionMode(QListView.ExtendedSelection) + 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) diff --git a/src/windows/views/transitions_listview.py b/src/windows/views/transitions_listview.py index dc46ae16c..7ab83cc29 100644 --- a/src/windows/views/transitions_listview.py +++ b/src/windows/views/transitions_listview.py @@ -116,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 From f4406d6f6a08e3a889aedfd74f0ab1ee59096e3e Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 3 Feb 2026 13:39:44 -0600 Subject: [PATCH 22/32] Protect against setting audio sample rate to None in CheckAudioDevice method --- src/windows/preview_thread.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/windows/preview_thread.py b/src/windows/preview_thread.py index 1a9e9b970..a60147286 100644 --- a/src/windows/preview_thread.py +++ b/src/windows/preview_thread.py @@ -170,6 +170,9 @@ def Init(self, parent, timeline, videoPreview): def CheckAudioDevice(self): """Check if any audio devices initialization errors, default sample rate, and current open audio device""" + s = get_app().get_settings() + detected_sample_rate_int = None + # Check audio init error audio_error = self.player.GetError() if audio_error: @@ -183,7 +186,6 @@ def CheckAudioDevice(self): # Convert float to Integer detected_sample_rate_int = round(detected_sample_rate) - s = get_app().get_settings() settings_sample_rate = int(s.get("default-samplerate") or 48000) if detected_sample_rate_int != settings_sample_rate: log.warning("Your sample rate (%d) does not match OpenShot (%d). " @@ -201,6 +203,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 From 0c10dc516613403cc0ba13e44ef93590c6fd1b6f Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 3 Feb 2026 13:40:42 -0600 Subject: [PATCH 23/32] Adding Android-only openshot_shiboken_ext handling for wrapInstance, due to large Arm64 memory addresses (PySide6 forces int() which breaks the pointers in an overflow error) --- src/qt_api.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/qt_api.py b/src/qt_api.py index d7640c0bf..ec09befd1 100644 --- a/src/qt_api.py +++ b/src/qt_api.py @@ -9,6 +9,8 @@ import logging import os +import ctypes +import sys from typing import List, Optional, Tuple logger = logging.getLogger(__name__) @@ -28,6 +30,24 @@ _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.""" @@ -71,7 +91,22 @@ def wrapinstance(ptr, base_type): raise RuntimeError("No SIP/shiboken module available for wrapinstance()") if backend == "sip": return mod.wrapinstance(ptr, base_type) - return mod.wrapInstance(int(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): From b3d7e6a9b86ff1fccec38a00ae1b61bb5dde95e6 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 3 Feb 2026 14:00:22 -0600 Subject: [PATCH 24/32] Protecting setTabOrder calls from potentially using widgets from different windows (quite noisy logs) --- src/classes/tabstops.py | 16 ++++++++++++++-- src/windows/animated_title.py | 4 ++-- src/windows/export.py | 4 ++-- src/windows/title_editor.py | 4 +++- src/windows/views/timeline.py | 3 ++- 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/classes/tabstops.py b/src/classes/tabstops.py index d3b3de738..b8228c66f 100644 --- a/src/classes/tabstops.py +++ b/src/classes/tabstops.py @@ -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/windows/animated_title.py b/src/windows/animated_title.py index 15f58d2e9..cd2352431 100644 --- a/src/windows/animated_title.py +++ b/src/windows/animated_title.py @@ -118,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/export.py b/src/windows/export.py index 11bdded64..524a65f26 100644 --- a/src/windows/export.py +++ b/src/windows/export.py @@ -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 diff --git a/src/windows/title_editor.py b/src/windows/title_editor.py index 26b6a1cf6..e594527fa 100644 --- a/src/windows/title_editor.py +++ b/src/windows/title_editor.py @@ -552,7 +552,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/views/timeline.py b/src/windows/views/timeline.py index 04afdb1a2..2fb303d35 100644 --- a/src/windows/views/timeline.py +++ b/src/windows/views/timeline.py @@ -330,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 From 18a95f035fe9d40a9bb2c3dc515355ea9fadedb2 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 3 Feb 2026 14:06:16 -0600 Subject: [PATCH 25/32] Moving to exec() method instead of exec_() when launching OpenShot (if available) --- src/launch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/launch.py b/src/launch.py index ca2c977ec..5ab1c9955 100755 --- a/src/launch.py +++ b/src/launch.py @@ -237,7 +237,7 @@ def main(): # Launch GUI and start event loop if app.gui(): - exec_fn = getattr(app, "exec_", None) or getattr(app, "exec", None) + 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()) From 9b604380dc78e5ade6d03861954f207775f63994 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 3 Feb 2026 14:06:31 -0600 Subject: [PATCH 26/32] Quite theme miss logs (too noisy at the moment) --- src/windows/views/timeline_backend/theme.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/windows/views/timeline_backend/theme.py b/src/windows/views/timeline_backend/theme.py index f21381f23..ce5c2f0b9 100644 --- a/src/windows/views/timeline_backend/theme.py +++ b/src/windows/views/timeline_backend/theme.py @@ -1648,7 +1648,7 @@ def _apply_css(theme: TimelineTheme, css: str, source: str = "css") -> TimelineT if not css: return theme - log_miss = True + log_miss = False _css_apply_background(theme, css, source, log_miss) _css_apply_clip(theme, css, source, log_miss) From 2ade796b48a30be6c1544571257760e55f75bbb8 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 3 Feb 2026 14:08:23 -0600 Subject: [PATCH 27/32] Reduce grid size of Emoji list view (they have too much horizontal padding/margin) --- src/windows/views/emojis_listview.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/windows/views/emojis_listview.py b/src/windows/views/emojis_listview.py index f30dcb1a1..a78dd5115 100644 --- a/src/windows/views/emojis_listview.py +++ b/src/windows/views/emojis_listview.py @@ -41,6 +41,8 @@ class EmojisListView(QListView): """ A QListView QWidget used on the main window """ drag_item_size = QSize(48, 48) drag_item_center = QPoint(24, 24) + emoji_icon_size = QSize(75, 75) + emoji_grid_size = QSize(80, 95) def dragEnterEvent(self, event): # If dragging urls onto widget, accept @@ -180,8 +182,8 @@ def __init__(self, model, *args): self.setDropIndicatorShown(True) # Setup header columns and layout - self.setIconSize(info.LIST_ICON_SIZE) - self.setGridSize(info.LIST_GRID_SIZE) + self.setIconSize(self.emoji_icon_size) + self.setGridSize(self.emoji_grid_size) self.setViewMode(QListView.IconMode) self.setResizeMode(QListView.Adjust) self.setUniformItemSizes(True) From 9829d07bc6aad58ca572bef48cf742d1de2a12bd Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 3 Feb 2026 14:23:01 -0600 Subject: [PATCH 28/32] Silence more theme misses (when applying theme colors, icons, and styles) --- src/windows/views/timeline_backend/theme.py | 71 ++++++++++++--------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/src/windows/views/timeline_backend/theme.py b/src/windows/views/timeline_backend/theme.py index ce5c2f0b9..e30eb1d8b 100644 --- a/src/windows/views/timeline_backend/theme.py +++ b/src/windows/views/timeline_backend/theme.py @@ -9,6 +9,9 @@ from classes.logger import log from classes.info import PATH +LOG_THEME_MISS = False +LOG_THEME_INFO = False + def _apply_overrides(obj, overrides: dict, *, allow_unknown: bool = False) -> None: """Apply keyword overrides to a theme object, optionally ignoring unknown keys.""" @@ -187,8 +190,8 @@ def _css_prop( prop: str, source: str, *, - log_selector: bool = True, - log_property: bool = True, + log_selector: bool = LOG_THEME_MISS, + log_property: bool = LOG_THEME_MISS, ) -> Optional[str]: """Return property *prop* from the CSS *selector* block. @@ -257,8 +260,8 @@ def _parse_color( prop: Union[str, Sequence[str]], source: str, *, - log_miss: bool = True, - log_selector: bool = True, + log_miss: bool = LOG_THEME_MISS, + log_selector: bool = LOG_THEME_MISS, ) -> Optional[QColor]: props = (prop,) if isinstance(prop, str) else tuple(prop) val = None @@ -313,7 +316,7 @@ def _parse_color( def _parse_gradient( - css: str, selector: str, prop: str, source: str, *, log_miss: bool = True + css: str, selector: str, prop: str, source: str, *, log_miss: bool = LOG_THEME_MISS ): """Return up to two colors from a CSS gradient. @@ -371,8 +374,8 @@ def _parse_float( prop: Union[str, Sequence[str]], source: str, *, - log_miss: bool = True, - log_selector: bool = True, + log_miss: bool = LOG_THEME_MISS, + log_selector: bool = LOG_THEME_MISS, ) -> Optional[float]: props = (prop,) if isinstance(prop, str) else tuple(prop) val = None @@ -419,7 +422,7 @@ def _parse_pixmap( prop: str, source: str, *, - log_miss: bool = True, + log_miss: bool = LOG_THEME_MISS, ) -> Optional[QPixmap]: val = _css_prop(css, selector, prop, source) if not val: @@ -447,7 +450,7 @@ def _parse_pixmap( if os.path.exists(path): img = _load_pixmap_with_meta(path) if img: - if selector in {".playhead-top", ".marker_icon"} and prop == "background-image": + if LOG_THEME_INFO and selector in {".playhead-top", ".marker_icon"} and prop == "background-image": log.info( "Theme [%s] %s %s loaded '%s'", source, @@ -503,7 +506,7 @@ def _parse_box_shadow( def _theme_pixmap( - qt_theme, selector: str, prop: str, *, log_miss: bool = True + qt_theme, selector: str, prop: str, *, log_miss: bool = LOG_THEME_MISS ) -> Optional[QPixmap]: if not qt_theme or not hasattr(qt_theme, "style_sheet"): return None @@ -550,7 +553,7 @@ def _theme_pixmap( if os.path.exists(path): img = _load_pixmap_with_meta(path) if img: - if selector in {".playhead-top", ".marker_icon"} and prop == "background-image": + if LOG_THEME_INFO and selector in {".playhead-top", ".marker_icon"} and prop == "background-image": log.info( "Theme [theme] %s %s loaded '%s'", selector, @@ -568,7 +571,7 @@ def _theme_get_color( selector: str, prop: Union[str, Sequence[str]], *, - log_miss: bool = True, + log_miss: bool = LOG_THEME_MISS, ): if not qt_theme: return None @@ -595,7 +598,7 @@ def _theme_get_int( selector: str, prop: Union[str, Sequence[str]], *, - log_miss: bool = True, + log_miss: bool = LOG_THEME_MISS, ): if not qt_theme: return None @@ -669,7 +672,7 @@ def _theme_apply_color( selector: str, prop: Union[str, Sequence[str]], *, - log_miss: bool = True, + log_miss: bool = LOG_THEME_MISS, ) -> None: col = _theme_get_color(qt_theme, selector, prop, log_miss=log_miss) _assign_color(target, attr, col) @@ -683,7 +686,7 @@ def _theme_apply_int( prop: Union[str, Sequence[str]], *, transform: Optional[Callable[[Union[int, float]], Union[int, float]]] = None, - log_miss: bool = True, + log_miss: bool = LOG_THEME_MISS, ) -> None: val = _theme_get_int(qt_theme, selector, prop, log_miss=log_miss) _assign_value(target, attr, val, transform=transform) @@ -716,8 +719,8 @@ def _apply_css_color_value( prop: Union[str, Sequence[str]], source: str, *, - log_miss: bool = True, - log_selector: bool = True, + log_miss: bool = LOG_THEME_MISS, + log_selector: bool = LOG_THEME_MISS, ) -> None: col = _parse_color( css, @@ -738,8 +741,8 @@ def _apply_css_float_value( prop: Union[str, Sequence[str]], source: str, *, - log_miss: bool = True, - log_selector: bool = True, + log_miss: bool = LOG_THEME_MISS, + log_selector: bool = LOG_THEME_MISS, transform: Optional[Callable[[float], Union[int, float]]] = None, ) -> None: val = _parse_float( @@ -991,8 +994,12 @@ def _theme_apply_ruler(theme: TimelineTheme, qt_theme, css_sheet: str) -> None: "background2", lambda: _ruler_gradient(css_sheet, "theme"), lambda: _ruler_theme_background(qt_theme), - miss_log=lambda: log.info( - "Theme MISS [theme] selector '#scrolling_ruler' property 'background'" + miss_log=( + (lambda: log.info( + "Theme MISS [theme] selector '#scrolling_ruler' property 'background'" + )) + if LOG_THEME_MISS + else None ), ) _apply_gradient_with_fallback( @@ -1432,9 +1439,13 @@ def _css_apply_ruler(theme: TimelineTheme, css: str, source: str, log_miss: bool "background2", lambda: _ruler_gradient(css, source), lambda: _ruler_css_background(css, source), - miss_log=lambda: log.info( - "Theme MISS [%s] selector '#scrolling_ruler' property 'background'", - source, + miss_log=( + (lambda: log.info( + "Theme MISS [%s] selector '#scrolling_ruler' property 'background'", + source, + )) + if LOG_THEME_MISS and log_miss + else None ), ) _apply_gradient_with_fallback( @@ -1690,10 +1701,11 @@ def apply_theme(widget, css: str = "") -> bool: default_path = os.path.normpath(os.path.join(base, "../images/playhead.svg")) if os.path.exists(default_path): t.playhead_icon = QPixmap(default_path) - log.info( - "Theme [default] .playhead-top background-image loaded '%s'", - default_path, - ) + if LOG_THEME_INFO: + log.info( + "Theme [default] .playhead-top background-image loaded '%s'", + default_path, + ) def _load_fallback_icon(*parts): path = os.path.normpath(os.path.join(PATH, *parts)) @@ -1702,7 +1714,8 @@ def _load_fallback_icon(*parts): pix = _load_pixmap_with_meta(path) if not pix: return None - log.info("Theme [default] fallback icon loaded '%s'", path) + if LOG_THEME_INFO: + log.info("Theme [default] fallback icon loaded '%s'", path) return pix def _ensure_icon(attr, parts): From 8daf0b73191f53883e538dcabbf14aa777c07d38 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Fri, 13 Feb 2026 23:44:32 -0600 Subject: [PATCH 29/32] Fixing merge regression with QRegEx import for Qt6 path --- src/windows/views/files_listview.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/windows/views/files_listview.py b/src/windows/views/files_listview.py index a86d4874b..300908b21 100644 --- a/src/windows/views/files_listview.py +++ b/src/windows/views/files_listview.py @@ -28,7 +28,7 @@ import uuid -from qt_api import QSize, Qt, QPoint, QRegExp +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 52705ac97675cbe5206b6c22bc67760c76b55dff Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Fri, 13 Feb 2026 23:52:26 -0600 Subject: [PATCH 30/32] Fixing more regressions from merging develop into this branch --- src/windows/views/emojis_listview.py | 64 +++++++++++-------- .../views/timeline_backend/qwidget/base.py | 1 + 2 files changed, 39 insertions(+), 26 deletions(-) diff --git a/src/windows/views/emojis_listview.py b/src/windows/views/emojis_listview.py index 2b66a7518..53b4b6bb3 100644 --- a/src/windows/views/emojis_listview.py +++ b/src/windows/views/emojis_listview.py @@ -25,8 +25,7 @@ along with OpenShot Library. If not, see . """ -import os -from qt_api import QMimeData, QSize, QPoint, Qt, QUrl, pyqtSlot, QRegularExpression +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) @@ -77,30 +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") 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() - - # End transaction - get_app().updates.transaction_id = None + 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 @@ -149,6 +151,9 @@ def filter_changed(self, 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() + if s.get("emoji_group_filter") != group_id: + s.set("emoji_group_filter", group_id) def refresh_view(self): """Filter emojis with proxy class""" @@ -184,6 +189,7 @@ def __init__(self, model, *args): # 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) @@ -211,8 +217,14 @@ def __init__(self, model, *args): # Activate filter and group selection _ = get_app()._tr self.win.emojisFilter.textChanged.connect(self.filter_changed) + s = get_app().get_settings() + default_group_id = s.get("emoji_group_filter") or "smileys-emotion" + dropdown_index = 0 self.win.emojiFilterGroup.clear() self.win.emojiFilterGroup.addItem(_("All"), "") - for name, group_id in sorted(self.emojis_model.emoji_groups, key=lambda g: g[0]): + 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/timeline_backend/qwidget/base.py b/src/windows/views/timeline_backend/qwidget/base.py index 36776f818..076f65a68 100644 --- a/src/windows/views/timeline_backend/qwidget/base.py +++ b/src/windows/views/timeline_backend/qwidget/base.py @@ -28,6 +28,7 @@ import json from functools import partial +import openshot 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 94c74d5c2295b52b448b2a3e87cdfed1ac070fce Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Fri, 13 Feb 2026 23:53:58 -0600 Subject: [PATCH 31/32] Another Qt6 regression with emoji dragging --- src/windows/views/timeline_backend/qwidget/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/windows/views/timeline_backend/qwidget/base.py b/src/windows/views/timeline_backend/qwidget/base.py index 076f65a68..78e17220d 100644 --- a/src/windows/views/timeline_backend/qwidget/base.py +++ b/src/windows/views/timeline_backend/qwidget/base.py @@ -1103,7 +1103,8 @@ def _event_seconds_track(self, event): 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 From 113dbf15f3adb173d0c53e2f6f3e7b52543113fe Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Sat, 14 Feb 2026 00:02:52 -0600 Subject: [PATCH 32/32] Another Qt6 regression with file adding and dragging --- .../views/timeline_backend/qwidget/base.py | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/windows/views/timeline_backend/qwidget/base.py b/src/windows/views/timeline_backend/qwidget/base.py index 78e17220d..89dcbe115 100644 --- a/src/windows/views/timeline_backend/qwidget/base.py +++ b/src/windows/views/timeline_backend/qwidget/base.py @@ -922,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: @@ -943,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() @@ -981,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: @@ -1011,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() @@ -1063,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: @@ -1079,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):