Source code for pyqt_reactive.widgets.shared.tear_off_tab_widget

"""
TearOffTabWidget - Chrome-style detachable tabs for PyQt6.

Allows users to drag tabs out to create floating windows,
and drag them into other windows to dock them.
"""

import logging
from dataclasses import dataclass
from typing import Optional, List, Callable
from PyQt6.QtWidgets import (
    QTabWidget, QTabBar, QWidget, QVBoxLayout, QDialog,
    QApplication, QFrame
)
from PyQt6.QtCore import Qt, pyqtSignal, QMimeData, QPoint, QRect
from PyQt6.QtGui import QDrag, QPixmap, QPainter, QColor, QCursor

logger = logging.getLogger(__name__)


[docs] @dataclass(frozen=True, slots=True) class FloatingWindowDragState: """Drag-state projection for a floating torn-off tab window.""" start_pos: Optional[QPoint] = None active: bool = False
[docs] @classmethod def begin(cls, start_pos: QPoint) -> "FloatingWindowDragState": return cls(start_pos=start_pos, active=False)
[docs] def activate_if_threshold_met( self, current_pos: QPoint, threshold: int, ) -> "FloatingWindowDragState": if self.start_pos is None or self.active: return self distance = (current_pos - self.start_pos).manhattanLength() if distance <= threshold: return self return FloatingWindowDragState(start_pos=self.start_pos, active=True)
[docs] def moved_to(self, current_pos: QPoint) -> "FloatingWindowDragState": return FloatingWindowDragState(start_pos=current_pos, active=self.active)
[docs] class TabDragData: """Data container for tab drag operations."""
[docs] def __init__(self, source_widget: 'TearOffTabWidget', tab_index: int, tab_text: str, tab_widget: QWidget): self.source_widget = source_widget self.tab_index = tab_index self.tab_text = tab_text self.tab_widget = tab_widget
[docs] class TearOffTabBar(QTabBar): """Custom tab bar that supports tear-off drag operations.""" # Signal emitted when a tab should be torn off tab_tear_off_requested = pyqtSignal(int, QPoint) # tab_index, global_pos
[docs] def __init__(self, parent=None): super().__init__(parent) self._drag_start_pos: Optional[QPoint] = None self._tear_off_threshold = 30 # pixels to drag before tearing off self.setMovable(True)
[docs] def mousePressEvent(self, event): """Track initial click position for drag detection.""" if event.button() == Qt.MouseButton.LeftButton: self._drag_start_pos = event.pos() super().mousePressEvent(event)
[docs] def mouseMoveEvent(self, event): """Detect drag-out for tear-off.""" if not self._drag_start_pos: super().mouseMoveEvent(event) return # Calculate drag distance drag_distance = (event.pos() - self._drag_start_pos).manhattanLength() # Check if dragged outside tab bar bounds (vertical tear-off) tab_rect = self.rect() pos_in_bar = event.pos() outside_vertically = pos_in_bar.y() < -self._tear_off_threshold or \ pos_in_bar.y() > tab_rect.height() + self._tear_off_threshold outside_horizontally = pos_in_bar.x() < -self._tear_off_threshold or \ pos_in_bar.x() > tab_rect.width() + self._tear_off_threshold # If dragged far enough outside tab bar, initiate tear-off if (outside_vertically or outside_horizontally) and drag_distance > self._tear_off_threshold: tab_index = self.tabAt(self._drag_start_pos) if tab_index >= 0: global_pos = self.mapToGlobal(event.pos()) self.tab_tear_off_requested.emit(tab_index, global_pos) self._drag_start_pos = None return super().mouseMoveEvent(event)
[docs] def mouseReleaseEvent(self, event): """Reset drag tracking.""" self._drag_start_pos = None super().mouseReleaseEvent(event)
[docs] class TearOffTabWidget(QTabWidget): """ QTabWidget with Chrome-style tear-off tab support. Allows dragging tabs out to create floating windows, and dragging them into other TearOffTabWidgets to dock. Features: - Drag tab out to create floating window - Drag tab into another TearOffTabWidget to dock - Visual feedback during drag (preview pixmap) - Automatic cleanup of empty floating windows Usage: tab_widget = TearOffTabWidget() tab_widget.addTab(widget, "Tab 1") tab_widget.addTab(widget2, "Tab 2") # Optional: Set callback when tab is torn off tab_widget.on_tab_torn_off = lambda tab_widget, tab_text: print(f"Torn off: {tab_text}") """ # Signals tab_torn_off = pyqtSignal(QWidget, str) # tab_widget, tab_text tab_docked = pyqtSignal(QWidget, str, int) # tab_widget, tab_text, index
[docs] def __init__(self, parent=None): super().__init__(parent) # Replace default tab bar with tear-off capable one self._tear_off_bar = TearOffTabBar(self) self._tear_off_bar.tab_tear_off_requested.connect(self._on_tear_off_requested) self.setTabBar(self._tear_off_bar) # Accept drops from other tear-off tabs self.setAcceptDrops(True) # Drag state self._current_drag: Optional[TabDragData] = None self._floating_window: Optional['FloatingTabWindow'] = None # Callbacks self.on_tab_torn_off: Optional[Callable[[QWidget, str], None]] = None self.on_tab_docked: Optional[Callable[[QWidget, str, int], None]] = None # Visual feedback self._drop_indicator: Optional[QFrame] = None # Register with TearOffRegistry from pyqt_reactive.widgets.shared.tear_off_registry import TearOffRegistry TearOffRegistry.register_target(self)
def _on_tear_off_requested(self, tab_index: int, global_pos: QPoint): """Handle tear-off request from tab bar.""" logger.debug(f"Tear off requested for tab {tab_index}") if tab_index < 0 or tab_index >= self.count(): return # Get tab info tab_text = self.tabText(tab_index) tab_widget = self.widget(tab_index) if not tab_widget: return # Create drag data self._current_drag = TabDragData(self, tab_index, tab_text, tab_widget) # Remove tab from this widget (but don't delete) self.removeTab(tab_index) # Create floating window self._create_floating_window(tab_widget, tab_text, global_pos) # Emit signal self.tab_torn_off.emit(tab_widget, tab_text) if self.on_tab_torn_off: self.on_tab_torn_off(tab_widget, tab_text) def _create_floating_window(self, tab_widget: QWidget, tab_text: str, global_pos: QPoint): """Create floating window with tab content.""" from pyqt_reactive.widgets.shared.tear_off_registry import TearOffRegistry self._floating_window = FloatingTabWindow(tab_widget, tab_text, self) # Position near cursor self._floating_window.move(global_pos - QPoint(50, 20)) self._floating_window.show() # Register with registry for drop detection TearOffRegistry.register_drag(self._current_drag, self._floating_window) logger.debug(f"Created floating window for tab: {tab_text}")
[docs] def dragEnterEvent(self, event): """Accept drag from other tear-off tabs.""" from pyqt_reactive.widgets.shared.tear_off_registry import TearOffRegistry drag_data = TearOffRegistry.get_current_drag() if drag_data and drag_data.source_widget != self: event.acceptProposedAction() self._show_drop_indicator(event.pos()) logger.debug("Drag enter accepted") else: event.ignore()
[docs] def dragMoveEvent(self, event): """Update drop indicator position.""" from pyqt_reactive.widgets.shared.tear_off_registry import TearOffRegistry drag_data = TearOffRegistry.get_current_drag() if drag_data and drag_data.source_widget != self: event.acceptProposedAction() self._update_drop_indicator(event.pos()) else: event.ignore()
[docs] def dragLeaveEvent(self, event): """Hide drop indicator.""" self._hide_drop_indicator()
[docs] def dropEvent(self, event): """Handle tab drop from another window.""" from pyqt_reactive.widgets.shared.tear_off_registry import TearOffRegistry self._hide_drop_indicator() drag_data = TearOffRegistry.get_current_drag() if not drag_data or drag_data.source_widget == self: event.ignore() return # Get drop position drop_index = self._calculate_drop_index(event.pos()) # Close floating window if drag_data.source_widget._floating_window: drag_data.source_widget._floating_window.close() drag_data.source_widget._floating_window = None # Add tab to this widget tab_widget = drag_data.tab_widget tab_text = drag_data.tab_text # Re-parent the widget tab_widget.setParent(None) new_index = self.insertTab(drop_index, tab_widget, tab_text) self.setCurrentIndex(new_index) # Clear drag data TearOffRegistry.clear_drag() drag_data.source_widget._current_drag = None # Emit signals self.tab_docked.emit(tab_widget, tab_text, new_index) if self.on_tab_docked: self.on_tab_docked(tab_widget, tab_text, new_index) event.acceptProposedAction() logger.debug(f"Tab dropped at index {new_index}: {tab_text}")
def _calculate_drop_index(self, pos: QPoint) -> int: """Calculate which tab index to drop at based on mouse position.""" # Find which tab the mouse is over for i in range(self.count()): tab_rect = self.tabBar().tabRect(i) if tab_rect.contains(pos): # Drop before this tab return i # Drop at end return self.count() def _show_drop_indicator(self, pos: QPoint): """Show visual indicator for drop position.""" if not self._drop_indicator: self._drop_indicator = QFrame(self) self._drop_indicator.setStyleSheet("background-color: #0078d4;") self._drop_indicator.setFixedWidth(3) drop_index = self._calculate_drop_index(pos) if drop_index < self.count(): # Show indicator before this tab tab_rect = self.tabBar().tabRect(drop_index) self._drop_indicator.setGeometry(tab_rect.left() - 2, tab_rect.top(), 3, tab_rect.height()) else: # Show indicator at end if self.count() > 0: last_rect = self.tabBar().tabRect(self.count() - 1) self._drop_indicator.setGeometry(last_rect.right() + 1, last_rect.top(), 3, last_rect.height()) else: self._drop_indicator.setGeometry(0, 0, 3, self.tabBar().height()) self._drop_indicator.show() self._drop_indicator.raise_() def _update_drop_indicator(self, pos: QPoint): """Update drop indicator position during drag.""" self._show_drop_indicator(pos) def _hide_drop_indicator(self): """Hide drop indicator.""" if self._drop_indicator: self._drop_indicator.hide()
[docs] def closeEvent(self, event): """Unregister from registry when closed.""" from pyqt_reactive.widgets.shared.tear_off_registry import TearOffRegistry TearOffRegistry.unregister_target(self) super().closeEvent(event)
[docs] class FloatingTabWindow(QDialog): """ Floating window for torn-off tabs. Contains a single tab's content and can be dragged to dock into other TearOffTabWidgets. """
[docs] def __init__(self, content_widget: QWidget, title: str, source_tab_widget: TearOffTabWidget, parent=None): super().__init__(parent) self.source_tab_widget = source_tab_widget self.content_widget = content_widget self.title = title self._drag_state = FloatingWindowDragState() # Window settings self.setWindowTitle(title) self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowStaysOnTopHint) self.resize(800, 600) # Setup UI self._setup_ui()
def _setup_ui(self): """Setup the floating window UI.""" layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) # Add content widget layout.addWidget(self.content_widget)
[docs] def mousePressEvent(self, event): """Start tracking for window drag.""" if event.button() == Qt.MouseButton.LeftButton: self._drag_state = FloatingWindowDragState.begin(event.globalPos()) super().mousePressEvent(event)
[docs] def mouseMoveEvent(self, event): """Handle window dragging and check for dock targets.""" from pyqt_reactive.widgets.shared.tear_off_registry import TearOffRegistry if self._drag_state.start_pos and event.buttons() == Qt.MouseButton.LeftButton: self._drag_state = self._drag_state.activate_if_threshold_met( event.globalPos(), threshold=10, ) if self._drag_state.active: # Move window delta = event.globalPos() - self._drag_state.start_pos self.move(self.pos() + delta) self._drag_state = self._drag_state.moved_to(event.globalPos()) # Check if over a dock target TearOffRegistry.check_hover(self, event.globalPos()) super().mouseMoveEvent(event)
[docs] def mouseReleaseEvent(self, event): """Handle drop - dock if over a target.""" from pyqt_reactive.widgets.shared.tear_off_registry import TearOffRegistry if self._drag_state.active and event.button() == Qt.MouseButton.LeftButton: # Check if we should dock target = TearOffRegistry.get_drop_target(self) if target: logger.debug(f"Docking into target: {target}") # The drop logic is handled by the target's dropEvent # We just need to trigger it TearOffRegistry.perform_drop(self, target) else: logger.debug("No dock target found, keeping window floating") self._drag_state = FloatingWindowDragState() super().mouseReleaseEvent(event)
[docs] def closeEvent(self, event): """Handle window close - return tab to source if not docked.""" from pyqt_reactive.widgets.shared.tear_off_registry import TearOffRegistry # Check if this is a clean close (not docking) drag_data = TearOffRegistry.get_current_drag() if drag_data and drag_data.source_widget == self.source_tab_widget: # Window closed without docking - re-add to source logger.debug("Floating window closed, returning tab to source") drag_data.source_widget.addTab(drag_data.tab_widget, drag_data.tab_text) TearOffRegistry.clear_drag() self.source_tab_widget._current_drag = None super().closeEvent(event)