from PySide6.QtCore import QEasingCurve, QPropertyAnimation, Qt, Signal from PySide6.QtGui import QPixmap from PySide6.QtOpenGLWidgets import QOpenGLWidget from PySide6.QtWidgets import ( QFrame, QGraphicsOpacityEffect, QHBoxLayout, QLabel, QStackedWidget, QTabBar, QVBoxLayout, QWidget, ) class FadingStackedWidget(QStackedWidget): """ A QStackedWidget that cross-fades between widgets. """ def __init__(self, parent=None): super().__init__(parent) self.fade_duration = 300 self.fade_easing = QEasingCurve.Type.OutQuad @staticmethod def _contains_gl(widget): """Whether an OpenGL surface lives anywhere under this page.""" if widget is None: return False return isinstance(widget, QOpenGLWidget) or widget.findChild(QOpenGLWidget) is not None def setCurrentIndex(self, index): curr_index = self.currentIndex() if index == curr_index: return widget = self.widget(index) curr_widget = self.widget(curr_index) # If widget isn't visible or valid, just swap if not self.isVisible() or not curr_widget: super().setCurrentIndex(index) return # Never fade a page containing an OpenGL surface. grab() on one # returns a black rectangle -- so the "fade" was a black slab sliding # over the new tab -- and forcing a framebuffer readback plus a # QGraphicsEffect over that subtree is a way to lose the GL context, # which for the embedded mpv player means a crash. if self._contains_gl(widget) or self._contains_gl(curr_widget): super().setCurrentIndex(index) return # 1. Capture the current view (the "old" tab) # Use grab() for simplicity and reliability in PySide6 pixmap = self.grab() # 2. Create an overlay label to hold this "old" view overlay = QLabel(self) overlay.setPixmap(pixmap) overlay.setGeometry(0, 0, self.width(), self.height()) overlay.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) # Don't block clicks overlay.show() # 3. Switch the actual stack to the "new" view super().setCurrentIndex(index) # CRITICAL: Ensure overlay stays on top of the new widget overlay.raise_() # 4. Fade OUT the overlay, revealing the new view effect = QGraphicsOpacityEffect(overlay) overlay.setGraphicsEffect(effect) anim = QPropertyAnimation(effect, b"opacity", overlay) anim.setDuration(self.fade_duration) anim.setStartValue(1.0) anim.setEndValue(0.0) anim.setEasingCurve(self.fade_easing) # Cleanup when done anim.finished.connect(lambda: self._cleanup(overlay)) # Keep reference to prevent GC self._active_anim = anim anim.start(QPropertyAnimation.DeletionPolicy.DeleteWhenStopped) def _cleanup(self, overlay): overlay.hide() overlay.deleteLater() class SmoothTabWidget(QWidget): """ A unified Widget that behaves like a QTabWidget but uses smooth fading transitions. Includes a QTabBar and a FadingStackedWidget. """ #: Emitted after the visible page has actually changed. The QTabBar's own #: currentChanged is not usable for this: it fires before the stack has #: switched, and it re-enters through set_current_index. currentChanged = Signal(int) def __init__(self, parent=None): super().__init__(parent) # Main Layout self.layout = QVBoxLayout(self) self.layout.setContentsMargins(0, 0, 0, 0) self.layout.setSpacing(0) # Tab bar, plus a right-aligned slot for a corner widget. Wrapped in a # row so something (the account button) can sit opposite the tabs the # way QTabWidget::setCornerWidget would allow. self.tab_row = QWidget(self) tab_row_layout = QHBoxLayout(self.tab_row) tab_row_layout.setContentsMargins(0, 0, 0, 0) tab_row_layout.setSpacing(0) self.tab_bar = QTabBar(self.tab_row) self.tab_bar.setDrawBase(False) # We draw border on content instead self.tab_bar.currentChanged.connect(self.set_current_index) tab_row_layout.addWidget(self.tab_bar) tab_row_layout.addStretch(1) self.corner_widget = None self._switching = False self._tab_row_layout = tab_row_layout self.layout.addWidget(self.tab_row) # Content Area (Frame) - Mimics QTabWidget::pane self.content_frame = QFrame(self) self.content_frame.setObjectName("tabContent") # Layout inside the content frame self.content_layout = QVBoxLayout(self.content_frame) self.content_layout.setContentsMargins(0, 0, 0, 0) self.content_layout.setSpacing(0) # The Stack self.stack = FadingStackedWidget(self.content_frame) self.content_layout.addWidget(self.stack) self.layout.addWidget(self.content_frame) def addTab(self, widget, label, icon=None): """Add a tab with the given widget, label and optional icon.""" self.stack.addWidget(widget) if icon is not None: self.tab_bar.addTab(icon, label) else: self.tab_bar.addTab(label) def setCornerWidget(self, widget): """Place a widget at the right-hand end of the tab row.""" if self.corner_widget is not None: self.corner_widget.setParent(None) self.corner_widget = widget if widget is not None: widget.setParent(self.tab_row) self._tab_row_layout.addWidget(widget) def set_current_index(self, index): """Slot to handle tab bar clicks.""" if index == self.stack.currentIndex() and self.tab_bar.currentIndex() == index: return # setCurrentIndex on the bar emits its own currentChanged, which is # connected straight back here -- and at that moment the stack has not # moved yet, so a plain index comparison does not catch the re-entry. # Without this flag every switch emitted currentChanged twice and each # page's activation hook ran twice. if self._switching: return self._switching = True try: self.tab_bar.setCurrentIndex(index) self.stack.setCurrentIndex(index) finally: self._switching = False self.currentChanged.emit(index) def currentWidget(self): return self.stack.currentWidget() def currentIndex(self): return self.stack.currentIndex()