Skip to content

Splitter Widgets

Detachable widget functionality and enhanced splitter behavior for QSplitter layouts.

Quick Start

from src.custom_widgets.widget_handlers.splitter_widgets.api import (
    SplitterDetachableManager,
    DetachableWidget,
    DelayedCursorSplitter,
)

# Create manager
manager = SplitterDetachableManager()

# Add delayed cursor behavior to splitter (avoids accidental resizing)
manager.add_delayed_cursor(splitter, delay_ms=400)

# Create a detachable widget panel
detachable = manager.create_detachable_widget(
    content_widget,
    title="Properties",
)

# Add to splitter
splitter.addWidget(detachable)

# Cleanup when done
manager.cleanup()

Components

SplitterDetachableManager

Central manager for detachable widgets and delayed cursors.

DetachableWidget

Widget wrapper that can be detached into a floating dialog.

DetachableTitleBar

Custom title bar with detach/attach buttons.

DelayedCursorSplitter

Splitter handle that only shows resize cursor after hover delay.

Files

File Purpose
api.py Public API exports
manager.py SplitterDetachableManager implementation
detachable_widgets.py DetachableWidget, DetachableDialog, DetachableTitleBar
delayed_cursor.py DelayedCursorSplitter implementation

What Belongs Here

Put here: - Splitter-related widgets and utilities - Detachable panel functionality - Splitter behavior enhancements

Do NOT put here: - General widget utilities (use parent widget_handlers/) - Non-splitter widgets

API Reference

src.custom_widgets.widget_handlers.splitter_widgets.api

Public API for splitter widgets module.

Provides detachable widget functionality and delayed cursor behavior for QSplitter-based layouts.

Example

Basic usage::

from src.custom_widgets.widget_handlers.splitter_widgets.api import (
    SplitterDetachableManager,
    DetachableWidget,
    DelayedCursorSplitter,
)

# Create manager
manager = SplitterDetachableManager()

# Add delayed cursor to splitter
manager.add_delayed_cursor(splitter, delay_ms=400)

# Create detachable widget
detachable = manager.create_detachable_widget(
    content_widget,
    title="Properties",
)

# Cleanup when done
manager.cleanup()

DelayedCursorSplitter

Bases: QObject

Standalone class for delayed cursor change on QSplitter handles.

Installs event filters on splitter handles to delay the resize cursor appearance. This prevents accidental resizing when users are just moving their mouse across the interface.

The cursor only changes to the resize cursor after the mouse has hovered over the handle for the specified delay period.

Attributes:

Name Type Description
splitter

The QSplitter being managed.

delay_ms

Delay in milliseconds before cursor change.

Example

Using with a splitter::

splitter = QSplitter(Qt.Vertical)
delayed = DelayedCursorSplitter(splitter, delay_ms=400)

# Later, when cleaning up
delayed.cleanup()
Source code in src\custom_widgets\widget_handlers\splitter_widgets\delayed_cursor.py
class DelayedCursorSplitter(QObject):
    """
    Standalone class for delayed cursor change on QSplitter handles.

    Installs event filters on splitter handles to delay the resize cursor
    appearance. This prevents accidental resizing when users are just
    moving their mouse across the interface.

    The cursor only changes to the resize cursor after the mouse has
    hovered over the handle for the specified delay period.

    Attributes:
        splitter: The QSplitter being managed.
        delay_ms: Delay in milliseconds before cursor change.

    Example:
        Using with a splitter::

            splitter = QSplitter(Qt.Vertical)
            delayed = DelayedCursorSplitter(splitter, delay_ms=400)

            # Later, when cleaning up
            delayed.cleanup()
    """

    def __init__(
        self,
        splitter: QSplitter,
        delay_ms: int = 300,
        parent: Optional[QObject] = None,
    ) -> None:
        """
        Initialize delayed cursor handling for a splitter.

        Args:
            splitter: The QSplitter to manage.
            delay_ms: Delay in milliseconds before showing resize cursor.
            parent: Parent QObject for ownership.
        """
        super().__init__(parent)
        self.splitter = splitter
        self.delay_ms = delay_ms

        # Timer for delayed cursor change
        self.timer = QTimer()
        self.timer.timeout.connect(self._change_cursor)
        self.timer.setSingleShot(True)

        # Tracking state
        self.cursor_changed: bool = False
        self.current_handle: Optional[QObject] = None
        self.handles_with_filter: Set[QObject] = set()

        # Configure all existing handles
        self.update_handles()

        # Watch splitter for dynamic changes
        splitter.installEventFilter(self)

    def eventFilter(self, obj: QObject, event: QEvent) -> bool:
        """
        Event filter for splitter and handle events.

        Args:
            obj: Object that received the event.
            event: The event.

        Returns:
            True if event was consumed, False otherwise.
        """
        # Watch splitter events for widget changes
        if obj == self.splitter:
            if event.type() in (QEvent.Type.ChildAdded, QEvent.Type.ChildRemoved):
                # Update handles when widgets are added/removed
                QTimer.singleShot(0, self.update_handles)
            return False

        # Handle events for splitter handles
        if event.type() == QEvent.Type.Enter:
            # Mouse entered the handle
            self.current_handle = obj
            self.timer.start(self.delay_ms)
            return False

        elif event.type() == QEvent.Type.Leave:
            # Mouse left the handle
            if obj == self.current_handle:
                self.timer.stop()
                self.current_handle = None
                if self.cursor_changed:
                    QApplication.restoreOverrideCursor()
                    self.cursor_changed = False
            return False

        elif event.type() == QEvent.Type.CursorChange:
            # Prevent automatic cursor changes by Qt
            if not self.cursor_changed:
                obj.setCursor(Qt.CursorShape.ArrowCursor)
                return True  # Consume event
            return False

        elif event.type() == QEvent.Type.Destroy:
            # Handle is being destroyed - remove from tracking
            self.handles_with_filter.discard(obj)
            if obj == self.current_handle:
                self.timer.stop()
                self.current_handle = None
                if self.cursor_changed:
                    QApplication.restoreOverrideCursor()
                    self.cursor_changed = False
            return False

        return False

    def update_handles(self) -> None:
        """Update event filters for all splitter handles."""
        current_handles: Set[QObject] = set()

        for i in range(self.splitter.count()):
            handle = self.splitter.handle(i)
            if handle:
                current_handles.add(handle)

                # Only configure new handles
                if handle not in self.handles_with_filter:
                    handle.setCursor(Qt.CursorShape.ArrowCursor)
                    handle.installEventFilter(self)
                    self.handles_with_filter.add(handle)

        # Remove tracking for removed handles
        removed_handles = self.handles_with_filter - current_handles
        self.handles_with_filter -= removed_handles

    def _change_cursor(self) -> None:
        """Change cursor after timer expires."""
        if self.current_handle and self.current_handle.underMouse():
            self.cursor_changed = True
            # Set cursor based on splitter orientation
            if self.splitter.orientation() == Qt.Orientation.Horizontal:
                QApplication.setOverrideCursor(QCursor(Qt.CursorShape.SplitHCursor))
            else:
                QApplication.setOverrideCursor(QCursor(Qt.CursorShape.SplitVCursor))

    def set_delay(self, delay_ms: int) -> None:
        """
        Change the cursor delay.

        Args:
            delay_ms: New delay in milliseconds.
        """
        self.delay_ms = delay_ms

    def cleanup(self) -> None:
        """Clean up - remove event filters and restore cursor."""
        # Stop timer
        self.timer.stop()

        # Restore cursor if changed
        if self.cursor_changed:
            QApplication.restoreOverrideCursor()
            self.cursor_changed = False

        # Remove event filters from all handles
        for handle in self.handles_with_filter:
            try:
                handle.removeEventFilter(self)
            except RuntimeError:
                # Handle may already be deleted
                pass

        # Remove splitter event filter
        try:
            self.splitter.removeEventFilter(self)
        except RuntimeError:
            pass

        self.handles_with_filter.clear()
__init__(splitter, delay_ms=300, parent=None)

Initialize delayed cursor handling for a splitter.

Parameters:

Name Type Description Default
splitter QSplitter

The QSplitter to manage.

required
delay_ms int

Delay in milliseconds before showing resize cursor.

300
parent Optional[QObject]

Parent QObject for ownership.

None
Source code in src\custom_widgets\widget_handlers\splitter_widgets\delayed_cursor.py
def __init__(
    self,
    splitter: QSplitter,
    delay_ms: int = 300,
    parent: Optional[QObject] = None,
) -> None:
    """
    Initialize delayed cursor handling for a splitter.

    Args:
        splitter: The QSplitter to manage.
        delay_ms: Delay in milliseconds before showing resize cursor.
        parent: Parent QObject for ownership.
    """
    super().__init__(parent)
    self.splitter = splitter
    self.delay_ms = delay_ms

    # Timer for delayed cursor change
    self.timer = QTimer()
    self.timer.timeout.connect(self._change_cursor)
    self.timer.setSingleShot(True)

    # Tracking state
    self.cursor_changed: bool = False
    self.current_handle: Optional[QObject] = None
    self.handles_with_filter: Set[QObject] = set()

    # Configure all existing handles
    self.update_handles()

    # Watch splitter for dynamic changes
    splitter.installEventFilter(self)
cleanup()

Clean up - remove event filters and restore cursor.

Source code in src\custom_widgets\widget_handlers\splitter_widgets\delayed_cursor.py
def cleanup(self) -> None:
    """Clean up - remove event filters and restore cursor."""
    # Stop timer
    self.timer.stop()

    # Restore cursor if changed
    if self.cursor_changed:
        QApplication.restoreOverrideCursor()
        self.cursor_changed = False

    # Remove event filters from all handles
    for handle in self.handles_with_filter:
        try:
            handle.removeEventFilter(self)
        except RuntimeError:
            # Handle may already be deleted
            pass

    # Remove splitter event filter
    try:
        self.splitter.removeEventFilter(self)
    except RuntimeError:
        pass

    self.handles_with_filter.clear()
eventFilter(obj, event)

Event filter for splitter and handle events.

Parameters:

Name Type Description Default
obj QObject

Object that received the event.

required
event QEvent

The event.

required

Returns:

Type Description
bool

True if event was consumed, False otherwise.

Source code in src\custom_widgets\widget_handlers\splitter_widgets\delayed_cursor.py
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
    """
    Event filter for splitter and handle events.

    Args:
        obj: Object that received the event.
        event: The event.

    Returns:
        True if event was consumed, False otherwise.
    """
    # Watch splitter events for widget changes
    if obj == self.splitter:
        if event.type() in (QEvent.Type.ChildAdded, QEvent.Type.ChildRemoved):
            # Update handles when widgets are added/removed
            QTimer.singleShot(0, self.update_handles)
        return False

    # Handle events for splitter handles
    if event.type() == QEvent.Type.Enter:
        # Mouse entered the handle
        self.current_handle = obj
        self.timer.start(self.delay_ms)
        return False

    elif event.type() == QEvent.Type.Leave:
        # Mouse left the handle
        if obj == self.current_handle:
            self.timer.stop()
            self.current_handle = None
            if self.cursor_changed:
                QApplication.restoreOverrideCursor()
                self.cursor_changed = False
        return False

    elif event.type() == QEvent.Type.CursorChange:
        # Prevent automatic cursor changes by Qt
        if not self.cursor_changed:
            obj.setCursor(Qt.CursorShape.ArrowCursor)
            return True  # Consume event
        return False

    elif event.type() == QEvent.Type.Destroy:
        # Handle is being destroyed - remove from tracking
        self.handles_with_filter.discard(obj)
        if obj == self.current_handle:
            self.timer.stop()
            self.current_handle = None
            if self.cursor_changed:
                QApplication.restoreOverrideCursor()
                self.cursor_changed = False
        return False

    return False
set_delay(delay_ms)

Change the cursor delay.

Parameters:

Name Type Description Default
delay_ms int

New delay in milliseconds.

required
Source code in src\custom_widgets\widget_handlers\splitter_widgets\delayed_cursor.py
def set_delay(self, delay_ms: int) -> None:
    """
    Change the cursor delay.

    Args:
        delay_ms: New delay in milliseconds.
    """
    self.delay_ms = delay_ms
update_handles()

Update event filters for all splitter handles.

Source code in src\custom_widgets\widget_handlers\splitter_widgets\delayed_cursor.py
def update_handles(self) -> None:
    """Update event filters for all splitter handles."""
    current_handles: Set[QObject] = set()

    for i in range(self.splitter.count()):
        handle = self.splitter.handle(i)
        if handle:
            current_handles.add(handle)

            # Only configure new handles
            if handle not in self.handles_with_filter:
                handle.setCursor(Qt.CursorShape.ArrowCursor)
                handle.installEventFilter(self)
                self.handles_with_filter.add(handle)

    # Remove tracking for removed handles
    removed_handles = self.handles_with_filter - current_handles
    self.handles_with_filter -= removed_handles

DetachableDialog

Bases: QDialog

Dialog container for detached widgets.

When a DetachableWidget is detached from its splitter, the content is moved into this dialog. Closing the dialog triggers reattachment.

Signals

reattach_requested: Emitted when user closes the dialog. parent_destroyed: Emitted when parent splitter is destroyed.

Example

Internal use by DetachableWidget::

dialog = DetachableDialog("My Widget", parent_window)
dialog.set_content_widget(content)
dialog.show()
Source code in src\custom_widgets\widget_handlers\splitter_widgets\detachable_widgets.py
class DetachableDialog(QDialog):
    """
    Dialog container for detached widgets.

    When a DetachableWidget is detached from its splitter, the content
    is moved into this dialog. Closing the dialog triggers reattachment.

    Signals:
        reattach_requested: Emitted when user closes the dialog.
        parent_destroyed: Emitted when parent splitter is destroyed.

    Example:
        Internal use by DetachableWidget::

            dialog = DetachableDialog("My Widget", parent_window)
            dialog.set_content_widget(content)
            dialog.show()
    """

    reattach_requested: Signal = Signal()
    parent_destroyed: Signal = Signal()

    def __init__(
        self,
        title: str,
        parent: Optional[QWidget] = None,
        dialog_stylesheets: Optional[List[str]] = None,
        dialog_module_folder: Optional[str] = None,
    ) -> None:
        """
        Initialize the detachable dialog.

        Args:
            title: Dialog window title.
            parent: Parent widget.
            dialog_stylesheets: Optional list of QSS filenames to apply.
            dialog_module_folder: Module folder for stylesheet lookup.
        """
        super().__init__(parent)
        self.setWindowTitle(title)
        self.setWindowFlags(
            Qt.WindowType.Window
            | Qt.WindowType.WindowMinMaxButtonsHint
            | Qt.WindowType.WindowCloseButtonHint
        )
        self.setObjectName("DetachableDialog")
        self._set_window_icon()

        # Layout for dialog content
        self._layout = QVBoxLayout()
        self._layout.setContentsMargins(0, 0, 0, 0)
        self.setLayout(self._layout)

        # Apply stylesheets if provided
        self._apply_dialog_stylesheets(dialog_stylesheets, dialog_module_folder)

    def _set_window_icon(self) -> None:
        """Set the window icon for the dialog."""
        try:
            from src.shared_services.rendering.icons.icon_paths import Icons
            from src.shared_services.path_management.api import get_path_str

            self.setWindowIcon(QIcon(get_path_str(Icons.Logos.GehaLogoPNG)))
        except ImportError:
            # Fallback if icon system not available
            pass

    def _apply_dialog_stylesheets(
        self,
        dialog_stylesheets: Optional[List[str]],
        dialog_module_folder: Optional[str],
    ) -> None:
        """
        Apply dialog stylesheets via StylesheetManager.

        Args:
            dialog_stylesheets: List of QSS filenames.
            dialog_module_folder: Module folder for stylesheet lookup.
        """
        if dialog_stylesheets and dialog_module_folder:
            try:
                from src.shared_services.rendering.stylesheets.stylesheet_manager import (
                    StylesheetManager,
                )

                # Register with stylesheet manager using module folder path
                # Note: This uses the legacy ViewStyleManager approach for compatibility
                from QssHandler.load_qss import ViewStyleManager

                style_manager = ViewStyleManager.instance()
                style_manager.register_view_styles(
                    self, dialog_stylesheets, module_folder=dialog_module_folder
                )
            except Exception:
                # Continue without styling if manager unavailable
                pass

    def set_content_widget(self, widget: QWidget) -> None:
        """
        Set the content widget for the dialog.

        Args:
            widget: Widget to display in the dialog.
        """
        self._layout.addWidget(widget)

    def ensure_dialog_on_screen(self) -> None:
        """Ensure the dialog is visible on screen."""
        dialog_rect = self.geometry()
        available_geometry = self._get_available_screen_geometry()

        if not available_geometry.contains(dialog_rect):
            safe_pos = self._calculate_safe_position(
                dialog_rect.size(), available_geometry
            )
            self.move(safe_pos)

    def _get_available_screen_geometry(self) -> QRect:
        """Get the available screen geometry."""
        app = QApplication.instance()
        if not app:
            return QRect(0, 0, 1920, 1080)

        # Find screen at current dialog position
        dialog_center = self.geometry().center()
        target_screen = app.screenAt(dialog_center)

        if not target_screen:
            target_screen = app.primaryScreen()

        if target_screen:
            return target_screen.availableGeometry()
        return QRect(0, 0, 1920, 1080)

    def _calculate_safe_position(
        self, dialog_size: QSize, available_area: QRect
    ) -> QPoint:
        """
        Calculate a safe position for the dialog.

        Args:
            dialog_size: Size of the dialog.
            available_area: Available screen area.

        Returns:
            Safe position point.
        """
        center_x = available_area.center().x() - dialog_size.width() // 2
        center_y = available_area.center().y() - dialog_size.height() // 2

        safe_x = max(
            available_area.left() + 50,
            min(center_x, available_area.right() - dialog_size.width() - 50),
        )
        safe_y = max(
            available_area.top() + 50,
            min(center_y, available_area.bottom() - dialog_size.height() - 50),
        )

        return QPoint(safe_x, safe_y)

    def showEvent(self, event) -> None:
        """Handle show event to ensure proper positioning."""
        super().showEvent(event)
        QTimer.singleShot(0, self.ensure_dialog_on_screen)

    @Slot()
    def on_parent_splitter_destroyed(self) -> None:
        """Handle parent splitter destruction gracefully."""
        self.parent_destroyed.emit()
        super().close()

    def closeEvent(self, event) -> None:
        """Handle close event to trigger reattachment."""
        self.reattach_requested.emit()
        event.accept()
__init__(title, parent=None, dialog_stylesheets=None, dialog_module_folder=None)

Initialize the detachable dialog.

Parameters:

Name Type Description Default
title str

Dialog window title.

required
parent Optional[QWidget]

Parent widget.

None
dialog_stylesheets Optional[List[str]]

Optional list of QSS filenames to apply.

None
dialog_module_folder Optional[str]

Module folder for stylesheet lookup.

None
Source code in src\custom_widgets\widget_handlers\splitter_widgets\detachable_widgets.py
def __init__(
    self,
    title: str,
    parent: Optional[QWidget] = None,
    dialog_stylesheets: Optional[List[str]] = None,
    dialog_module_folder: Optional[str] = None,
) -> None:
    """
    Initialize the detachable dialog.

    Args:
        title: Dialog window title.
        parent: Parent widget.
        dialog_stylesheets: Optional list of QSS filenames to apply.
        dialog_module_folder: Module folder for stylesheet lookup.
    """
    super().__init__(parent)
    self.setWindowTitle(title)
    self.setWindowFlags(
        Qt.WindowType.Window
        | Qt.WindowType.WindowMinMaxButtonsHint
        | Qt.WindowType.WindowCloseButtonHint
    )
    self.setObjectName("DetachableDialog")
    self._set_window_icon()

    # Layout for dialog content
    self._layout = QVBoxLayout()
    self._layout.setContentsMargins(0, 0, 0, 0)
    self.setLayout(self._layout)

    # Apply stylesheets if provided
    self._apply_dialog_stylesheets(dialog_stylesheets, dialog_module_folder)
closeEvent(event)

Handle close event to trigger reattachment.

Source code in src\custom_widgets\widget_handlers\splitter_widgets\detachable_widgets.py
def closeEvent(self, event) -> None:
    """Handle close event to trigger reattachment."""
    self.reattach_requested.emit()
    event.accept()
ensure_dialog_on_screen()

Ensure the dialog is visible on screen.

Source code in src\custom_widgets\widget_handlers\splitter_widgets\detachable_widgets.py
def ensure_dialog_on_screen(self) -> None:
    """Ensure the dialog is visible on screen."""
    dialog_rect = self.geometry()
    available_geometry = self._get_available_screen_geometry()

    if not available_geometry.contains(dialog_rect):
        safe_pos = self._calculate_safe_position(
            dialog_rect.size(), available_geometry
        )
        self.move(safe_pos)
on_parent_splitter_destroyed()

Handle parent splitter destruction gracefully.

Source code in src\custom_widgets\widget_handlers\splitter_widgets\detachable_widgets.py
@Slot()
def on_parent_splitter_destroyed(self) -> None:
    """Handle parent splitter destruction gracefully."""
    self.parent_destroyed.emit()
    super().close()
set_content_widget(widget)

Set the content widget for the dialog.

Parameters:

Name Type Description Default
widget QWidget

Widget to display in the dialog.

required
Source code in src\custom_widgets\widget_handlers\splitter_widgets\detachable_widgets.py
def set_content_widget(self, widget: QWidget) -> None:
    """
    Set the content widget for the dialog.

    Args:
        widget: Widget to display in the dialog.
    """
    self._layout.addWidget(widget)
showEvent(event)

Handle show event to ensure proper positioning.

Source code in src\custom_widgets\widget_handlers\splitter_widgets\detachable_widgets.py
def showEvent(self, event) -> None:
    """Handle show event to ensure proper positioning."""
    super().showEvent(event)
    QTimer.singleShot(0, self.ensure_dialog_on_screen)

DetachableTitleBar

Bases: QWidget

Title bar for detachable widgets.

Displays the widget title and a detach button that allows users to detach the widget into a floating dialog.

Signals

detach_requested: Emitted when user clicks the detach button.

Example

Internal use by DetachableWidget::

title_bar = DetachableTitleBar("My Widget")
title_bar.detach_requested.connect(self.detach)
Source code in src\custom_widgets\widget_handlers\splitter_widgets\detachable_widgets.py
class DetachableTitleBar(QWidget):
    """
    Title bar for detachable widgets.

    Displays the widget title and a detach button that allows
    users to detach the widget into a floating dialog.

    Signals:
        detach_requested: Emitted when user clicks the detach button.

    Example:
        Internal use by DetachableWidget::

            title_bar = DetachableTitleBar("My Widget")
            title_bar.detach_requested.connect(self.detach)
    """

    detach_requested: Signal = Signal()

    def __init__(self, title: str, parent: Optional[QWidget] = None) -> None:
        """
        Initialize the title bar.

        Args:
            title: Title text to display.
            parent: Parent widget.
        """
        super().__init__(parent)
        self.title = title
        self._setup_ui()

    def _setup_ui(self) -> None:
        """Set up the title bar UI."""
        main_layout = QHBoxLayout()
        main_layout.setContentsMargins(0, 0, 0, 0)
        main_layout.setSpacing(0)

        self.container = QWidget()
        self.container.setObjectName("DetachableTitleBar_Container")

        container_layout = QHBoxLayout()
        container_layout.setContentsMargins(8, 4, 8, 4)
        container_layout.setSpacing(8)

        # Title label
        self.title_label = QLabel(self.title)
        self.title_label.setObjectName("DetachableTitleBar_Label")

        # Detach button - icon only with hover effect
        self.detach_button = QPushButton()
        self.detach_button.setObjectName("DetachableTitleBar_DetachButton")
        self.detach_button.setFixedSize(24, 24)
        self.detach_button.setCursor(Qt.CursorShape.PointingHandCursor)
        self._set_detach_button_icon()
        self.detach_button.clicked.connect(self.detach_requested.emit)

        # Assemble layout
        container_layout.addWidget(self.title_label)
        container_layout.addStretch()
        container_layout.addWidget(self.detach_button)

        self.container.setLayout(container_layout)
        main_layout.addWidget(self.container)
        self.setLayout(main_layout)

        # Fixed height
        self.setFixedHeight(32)

    def _set_detach_button_icon(self) -> None:
        """Set the icon for the detach button."""
        try:
            from src.shared_services.rendering.icons.icon_paths import Icons
            from src.shared_services.rendering.icons.api import render_svg

            icon_pixmap = render_svg(Icons.Toggle.DetachDialog, size=18)
            self.detach_button.setIcon(QIcon(icon_pixmap))
            self.detach_button.setIconSize(QSize(18, 18))
        except ImportError:
            # Fallback to text if icon system unavailable
            self.detach_button.setText("D")
__init__(title, parent=None)

Initialize the title bar.

Parameters:

Name Type Description Default
title str

Title text to display.

required
parent Optional[QWidget]

Parent widget.

None
Source code in src\custom_widgets\widget_handlers\splitter_widgets\detachable_widgets.py
def __init__(self, title: str, parent: Optional[QWidget] = None) -> None:
    """
    Initialize the title bar.

    Args:
        title: Title text to display.
        parent: Parent widget.
    """
    super().__init__(parent)
    self.title = title
    self._setup_ui()

DetachableWidget

Bases: QFrame

Widget container that can be detached from a splitter.

Wraps a content widget with a title bar containing a detach button. When detached, the content moves to a floating dialog. Closing the dialog reattaches the widget to its original position.

Attributes:

Name Type Description
content_widget

The wrapped content widget.

title

Widget title displayed in title bar.

dialog Optional[DetachableDialog]

The detached dialog (None when attached).

Example

Creating a detachable widget::

editor = QTextEdit()
detachable = DetachableWidget(
    editor,
    title="Text Editor",
    dialog_stylesheets=["editor.qss"],
    dialog_module_folder="MyModule/Editors",
)
splitter.addWidget(detachable)
Source code in src\custom_widgets\widget_handlers\splitter_widgets\detachable_widgets.py
class DetachableWidget(QFrame):
    """
    Widget container that can be detached from a splitter.

    Wraps a content widget with a title bar containing a detach button.
    When detached, the content moves to a floating dialog. Closing the
    dialog reattaches the widget to its original position.

    Attributes:
        content_widget: The wrapped content widget.
        title: Widget title displayed in title bar.
        dialog: The detached dialog (None when attached).

    Example:
        Creating a detachable widget::

            editor = QTextEdit()
            detachable = DetachableWidget(
                editor,
                title="Text Editor",
                dialog_stylesheets=["editor.qss"],
                dialog_module_folder="MyModule/Editors",
            )
            splitter.addWidget(detachable)
    """

    def __init__(
        self,
        content_widget: QWidget,
        title: str = "",
        dialog_stylesheets: Optional[List[str]] = None,
        dialog_module_folder: Optional[str] = None,
        parent: Optional[QWidget] = None,
    ) -> None:
        """
        Initialize the detachable widget.

        Args:
            content_widget: Widget to wrap.
            title: Title for the title bar and detached dialog.
            dialog_stylesheets: QSS files to apply to detached dialog.
            dialog_module_folder: Module folder for stylesheet lookup.
            parent: Parent widget.
        """
        super().__init__(parent)
        self.setObjectName("DetachableWidget")
        self.content_widget = content_widget
        self.title = title or "Widget"
        self.dialog: Optional[DetachableDialog] = None
        self.placeholder: Optional[QWidget] = None
        self.saved_index: int = -1
        self.saved_sizes: List[int] = []
        self.parent_splitter: Optional[QSplitter] = None

        # Store stylesheet parameters for dialog
        self.dialog_stylesheets = dialog_stylesheets
        self.dialog_module_folder = dialog_module_folder

        self._setup_ui()
        self._register_stylesheet()

    def _setup_ui(self) -> None:
        """Set up the widget UI."""
        layout = QVBoxLayout()
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(0)

        # Title bar
        self.title_bar = DetachableTitleBar(self.title)
        self.title_bar.detach_requested.connect(self.detach)

        # Content container
        content_container = QWidget()
        content_container.setObjectName("DetachableWidget_ContentContainer")

        content_layout = QVBoxLayout()
        content_layout.setContentsMargins(1, 1, 1, 1)
        content_layout.addWidget(self.content_widget)
        content_container.setLayout(content_layout)

        # Assemble layout
        layout.addWidget(self.title_bar)
        layout.addWidget(content_container)

        self.setLayout(layout)

    def _register_stylesheet(self) -> None:
        """Register with StylesheetManager for theme-aware styling."""
        try:
            from src.shared_services.rendering.stylesheets.stylesheet_manager import (
                StylesheetManager,
            )

            manager = StylesheetManager.instance()
            manager.register(self, [WidgetStylesheets.DetachableWidget])
        except ImportError:
            pass

    def _set_reattach_button_icon(self, button: QPushButton) -> None:
        """
        Set the icon for a reattach button.

        Args:
            button: The button to set the icon on.
        """
        try:
            from src.shared_services.rendering.icons.icon_paths import Icons
            from src.shared_services.rendering.icons.api import render_svg

            icon_pixmap = render_svg(Icons.Toggle.ReattachDialog, size=14)
            button.setIcon(QIcon(icon_pixmap))
            button.setIconSize(QSize(14, 14))
        except ImportError:
            # Fallback to text if icon system unavailable
            button.setText("R")

    def detach(self) -> None:
        """Detach the widget and display as a floating dialog."""
        # Find parent splitter
        parent = self.parent()
        while parent and not isinstance(parent, QSplitter):
            parent = parent.parent()

        if not parent:
            return

        self.parent_splitter = parent

        # Save position and sizes
        self.saved_index = self.parent_splitter.indexOf(self)
        self.saved_sizes = self.parent_splitter.sizes()

        # Create placeholder with reattach button
        self.placeholder = QWidget()
        self.placeholder.setObjectName("DetachableWidget_Placeholder")
        placeholder_layout = QHBoxLayout()
        placeholder_layout.setContentsMargins(4, 2, 4, 2)
        placeholder_layout.setSpacing(8)

        placeholder_label = QLabel(f"{self.title} (detached)")
        placeholder_label.setObjectName("DetachableWidget_PlaceholderLabel")
        placeholder_label.setAlignment(Qt.AlignmentFlag.AlignCenter)

        # Reattach button - icon only with hover effect
        reattach_button = QPushButton()
        reattach_button.setObjectName("DetachableWidget_ReattachButton")
        reattach_button.setFixedSize(20, 20)
        reattach_button.setCursor(Qt.CursorShape.PointingHandCursor)
        self._set_reattach_button_icon(reattach_button)
        reattach_button.clicked.connect(self.reattach)

        placeholder_layout.addWidget(placeholder_label)
        placeholder_layout.addWidget(reattach_button)
        self.placeholder.setLayout(placeholder_layout)

        # Minimal height for placeholder
        self.placeholder.setMinimumHeight(20)
        self.placeholder.setMaximumHeight(25)
        self.placeholder.setSizePolicy(
            QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
        )

        # Replace widget with placeholder
        self.parent_splitter.replaceWidget(self.saved_index, self.placeholder)

        # Create dialog
        self.dialog = DetachableDialog(
            self.title,
            self.window(),
            dialog_stylesheets=self.dialog_stylesheets,
            dialog_module_folder=self.dialog_module_folder,
        )
        self.dialog.reattach_requested.connect(self.reattach)

        # Connect parent splitter destruction
        self.parent_splitter.destroyed.connect(self.dialog.on_parent_splitter_destroyed)

        # Move content to dialog
        self.content_widget.setParent(None)
        self.dialog.set_content_widget(self.content_widget)

        # Set dialog size based on original widget
        dialog_width = max(400, self.width())
        dialog_height = max(300, self.height())
        self.dialog.resize(dialog_width, dialog_height)

        # Calculate safe position and show
        safe_position = self._calculate_safe_dialog_position(
            QSize(dialog_width, dialog_height)
        )
        self.dialog.move(safe_position)

        self.dialog.show()

        # Hide this widget
        self.hide()

    def reattach(self) -> None:
        """Reattach the widget to its original splitter position."""
        if not self.parent_splitter:
            return

        if not self.dialog:
            return

        # Safety checks for destroyed Qt objects
        try:
            _ = self.parent_splitter.count()
        except (RuntimeError, AttributeError):
            self.dialog = None
            self.placeholder = None
            return

        try:
            _ = self.dialog.isVisible()
        except (RuntimeError, AttributeError):
            self.dialog = None
            return

        # Return content to this widget
        self.content_widget.setParent(None)
        content_container = self.layout().itemAt(1).widget()
        content_container.layout().addWidget(self.content_widget)

        # Replace placeholder with this widget
        if self.placeholder:
            placeholder_index = self.parent_splitter.indexOf(self.placeholder)
            if placeholder_index >= 0:
                self.parent_splitter.replaceWidget(placeholder_index, self)
            else:
                self.parent_splitter.insertWidget(self.saved_index, self)

            self.placeholder.deleteLater()
            self.placeholder = None

        # Restore sizes
        if self.saved_sizes:
            self.parent_splitter.setSizes(self.saved_sizes)

        # Close dialog safely
        if self.dialog:
            try:
                self.dialog.close()
                self.dialog.deleteLater()
            except (RuntimeError, AttributeError):
                pass
            finally:
                self.dialog = None

        # Show this widget
        self.show()

    def _calculate_safe_dialog_position(self, dialog_size: QSize) -> QPoint:
        """
        Calculate a safe position for the detached dialog.

        Args:
            dialog_size: Size of the dialog.

        Returns:
            Safe position point.
        """
        app = QApplication.instance()
        if not app:
            return QPoint(200, 200)

        # Get screen geometry
        main_window = self.window()
        if main_window:
            main_window_center = main_window.geometry().center()
            target_screen = app.screenAt(main_window_center)
        else:
            target_screen = app.primaryScreen()

        if not target_screen:
            return QPoint(200, 200)

        available_geometry = target_screen.availableGeometry()

        # Preferred position near main window
        preferred_pos = QPoint(200, 200)

        if main_window:
            main_pos = main_window.pos()
            preferred_pos = QPoint(main_pos.x() + 100, main_pos.y() + 100)

        # Calculate safe position
        safe_x = max(
            available_geometry.left() + 50,
            min(
                preferred_pos.x(),
                available_geometry.right() - dialog_size.width() - 50,
            ),
        )
        safe_y = max(
            available_geometry.top() + 50,
            min(
                preferred_pos.y(),
                available_geometry.bottom() - dialog_size.height() - 50,
            ),
        )

        return QPoint(safe_x, safe_y)
__init__(content_widget, title='', dialog_stylesheets=None, dialog_module_folder=None, parent=None)

Initialize the detachable widget.

Parameters:

Name Type Description Default
content_widget QWidget

Widget to wrap.

required
title str

Title for the title bar and detached dialog.

''
dialog_stylesheets Optional[List[str]]

QSS files to apply to detached dialog.

None
dialog_module_folder Optional[str]

Module folder for stylesheet lookup.

None
parent Optional[QWidget]

Parent widget.

None
Source code in src\custom_widgets\widget_handlers\splitter_widgets\detachable_widgets.py
def __init__(
    self,
    content_widget: QWidget,
    title: str = "",
    dialog_stylesheets: Optional[List[str]] = None,
    dialog_module_folder: Optional[str] = None,
    parent: Optional[QWidget] = None,
) -> None:
    """
    Initialize the detachable widget.

    Args:
        content_widget: Widget to wrap.
        title: Title for the title bar and detached dialog.
        dialog_stylesheets: QSS files to apply to detached dialog.
        dialog_module_folder: Module folder for stylesheet lookup.
        parent: Parent widget.
    """
    super().__init__(parent)
    self.setObjectName("DetachableWidget")
    self.content_widget = content_widget
    self.title = title or "Widget"
    self.dialog: Optional[DetachableDialog] = None
    self.placeholder: Optional[QWidget] = None
    self.saved_index: int = -1
    self.saved_sizes: List[int] = []
    self.parent_splitter: Optional[QSplitter] = None

    # Store stylesheet parameters for dialog
    self.dialog_stylesheets = dialog_stylesheets
    self.dialog_module_folder = dialog_module_folder

    self._setup_ui()
    self._register_stylesheet()
detach()

Detach the widget and display as a floating dialog.

Source code in src\custom_widgets\widget_handlers\splitter_widgets\detachable_widgets.py
def detach(self) -> None:
    """Detach the widget and display as a floating dialog."""
    # Find parent splitter
    parent = self.parent()
    while parent and not isinstance(parent, QSplitter):
        parent = parent.parent()

    if not parent:
        return

    self.parent_splitter = parent

    # Save position and sizes
    self.saved_index = self.parent_splitter.indexOf(self)
    self.saved_sizes = self.parent_splitter.sizes()

    # Create placeholder with reattach button
    self.placeholder = QWidget()
    self.placeholder.setObjectName("DetachableWidget_Placeholder")
    placeholder_layout = QHBoxLayout()
    placeholder_layout.setContentsMargins(4, 2, 4, 2)
    placeholder_layout.setSpacing(8)

    placeholder_label = QLabel(f"{self.title} (detached)")
    placeholder_label.setObjectName("DetachableWidget_PlaceholderLabel")
    placeholder_label.setAlignment(Qt.AlignmentFlag.AlignCenter)

    # Reattach button - icon only with hover effect
    reattach_button = QPushButton()
    reattach_button.setObjectName("DetachableWidget_ReattachButton")
    reattach_button.setFixedSize(20, 20)
    reattach_button.setCursor(Qt.CursorShape.PointingHandCursor)
    self._set_reattach_button_icon(reattach_button)
    reattach_button.clicked.connect(self.reattach)

    placeholder_layout.addWidget(placeholder_label)
    placeholder_layout.addWidget(reattach_button)
    self.placeholder.setLayout(placeholder_layout)

    # Minimal height for placeholder
    self.placeholder.setMinimumHeight(20)
    self.placeholder.setMaximumHeight(25)
    self.placeholder.setSizePolicy(
        QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
    )

    # Replace widget with placeholder
    self.parent_splitter.replaceWidget(self.saved_index, self.placeholder)

    # Create dialog
    self.dialog = DetachableDialog(
        self.title,
        self.window(),
        dialog_stylesheets=self.dialog_stylesheets,
        dialog_module_folder=self.dialog_module_folder,
    )
    self.dialog.reattach_requested.connect(self.reattach)

    # Connect parent splitter destruction
    self.parent_splitter.destroyed.connect(self.dialog.on_parent_splitter_destroyed)

    # Move content to dialog
    self.content_widget.setParent(None)
    self.dialog.set_content_widget(self.content_widget)

    # Set dialog size based on original widget
    dialog_width = max(400, self.width())
    dialog_height = max(300, self.height())
    self.dialog.resize(dialog_width, dialog_height)

    # Calculate safe position and show
    safe_position = self._calculate_safe_dialog_position(
        QSize(dialog_width, dialog_height)
    )
    self.dialog.move(safe_position)

    self.dialog.show()

    # Hide this widget
    self.hide()
reattach()

Reattach the widget to its original splitter position.

Source code in src\custom_widgets\widget_handlers\splitter_widgets\detachable_widgets.py
def reattach(self) -> None:
    """Reattach the widget to its original splitter position."""
    if not self.parent_splitter:
        return

    if not self.dialog:
        return

    # Safety checks for destroyed Qt objects
    try:
        _ = self.parent_splitter.count()
    except (RuntimeError, AttributeError):
        self.dialog = None
        self.placeholder = None
        return

    try:
        _ = self.dialog.isVisible()
    except (RuntimeError, AttributeError):
        self.dialog = None
        return

    # Return content to this widget
    self.content_widget.setParent(None)
    content_container = self.layout().itemAt(1).widget()
    content_container.layout().addWidget(self.content_widget)

    # Replace placeholder with this widget
    if self.placeholder:
        placeholder_index = self.parent_splitter.indexOf(self.placeholder)
        if placeholder_index >= 0:
            self.parent_splitter.replaceWidget(placeholder_index, self)
        else:
            self.parent_splitter.insertWidget(self.saved_index, self)

        self.placeholder.deleteLater()
        self.placeholder = None

    # Restore sizes
    if self.saved_sizes:
        self.parent_splitter.setSizes(self.saved_sizes)

    # Close dialog safely
    if self.dialog:
        try:
            self.dialog.close()
            self.dialog.deleteLater()
        except (RuntimeError, AttributeError):
            pass
        finally:
            self.dialog = None

    # Show this widget
    self.show()

SplitterDetachableManager

Bases: QObject

Manager for splitters with detachable widgets.

Provides centralized management of: - Delayed cursor functionality for splitter handles - Creation and tracking of detachable widgets

The manager maintains references to all configured splitters and detachable widgets, allowing for centralized cleanup.

Attributes:

Name Type Description
delayed_cursors Dict[QSplitter, DelayedCursorSplitter]

Map of splitters to their DelayedCursorSplitter.

detachable_widgets List[DetachableWidget]

List of created DetachableWidget instances.

default_cursor_delay int

Default delay for cursor changes (ms).

Example

Managing multiple splitters::

manager = SplitterDetachableManager()

# Configure splitters
manager.add_delayed_cursor(horizontal_splitter)
manager.add_delayed_cursor(vertical_splitter, delay_ms=500)

# Create detachable widgets
props = manager.create_detachable_widget(props_widget, "Properties")
log = manager.create_detachable_widget(log_widget, "Log Viewer")

horizontal_splitter.addWidget(props)
vertical_splitter.addWidget(log)

# When view is closed
manager.cleanup()
Source code in src\custom_widgets\widget_handlers\splitter_widgets\manager.py
class SplitterDetachableManager(QObject):
    """
    Manager for splitters with detachable widgets.

    Provides centralized management of:
    - Delayed cursor functionality for splitter handles
    - Creation and tracking of detachable widgets

    The manager maintains references to all configured splitters and
    detachable widgets, allowing for centralized cleanup.

    Attributes:
        delayed_cursors: Map of splitters to their DelayedCursorSplitter.
        detachable_widgets: List of created DetachableWidget instances.
        default_cursor_delay: Default delay for cursor changes (ms).

    Example:
        Managing multiple splitters::

            manager = SplitterDetachableManager()

            # Configure splitters
            manager.add_delayed_cursor(horizontal_splitter)
            manager.add_delayed_cursor(vertical_splitter, delay_ms=500)

            # Create detachable widgets
            props = manager.create_detachable_widget(props_widget, "Properties")
            log = manager.create_detachable_widget(log_widget, "Log Viewer")

            horizontal_splitter.addWidget(props)
            vertical_splitter.addWidget(log)

            # When view is closed
            manager.cleanup()
    """

    def __init__(self, parent: Optional[QObject] = None) -> None:
        """
        Initialize the splitter manager.

        Args:
            parent: Parent QObject for ownership.
        """
        super().__init__(parent)

        self.delayed_cursors: Dict[QSplitter, DelayedCursorSplitter] = {}
        self.detachable_widgets: List[DetachableWidget] = []
        self.default_cursor_delay: int = 300

    def add_delayed_cursor(
        self,
        splitter: QSplitter,
        delay_ms: Optional[int] = None,
    ) -> DelayedCursorSplitter:
        """
        Add delayed cursor functionality to a splitter.

        Args:
            splitter: QSplitter to configure.
            delay_ms: Delay in milliseconds before cursor change.
                      Uses default_cursor_delay if not specified.

        Returns:
            The DelayedCursorSplitter instance managing this splitter.
        """
        if delay_ms is None:
            delay_ms = self.default_cursor_delay

        if splitter not in self.delayed_cursors:
            delayed_cursor = DelayedCursorSplitter(splitter, delay_ms, parent=self)
            self.delayed_cursors[splitter] = delayed_cursor
            return delayed_cursor

        return self.delayed_cursors[splitter]

    def create_detachable_widget(
        self,
        content: QWidget,
        title: str = "",
        dialog_stylesheets: Optional[List[str]] = None,
        dialog_module_folder: Optional[str] = None,
    ) -> DetachableWidget:
        """
        Create a detachable widget wrapping the given content.

        Args:
            content: Widget to wrap in detachable container.
            title: Title for title bar and detached dialog.
            dialog_stylesheets: QSS files to apply to detached dialog.
            dialog_module_folder: Module folder for stylesheet lookup.

        Returns:
            DetachableWidget instance wrapping the content.
        """
        detachable = DetachableWidget(
            content,
            title,
            dialog_stylesheets=dialog_stylesheets,
            dialog_module_folder=dialog_module_folder,
        )
        self.detachable_widgets.append(detachable)
        return detachable

    def cleanup(self) -> None:
        """
        Clean up all managed splitters and widgets.

        Removes event filters from splitters and releases references.
        Should be called when the parent view is being destroyed.
        """
        for delayed_cursor in self.delayed_cursors.values():
            delayed_cursor.cleanup()
        self.delayed_cursors.clear()

        # Note: DetachableWidgets are owned by their parent splitters
        # and will be cleaned up automatically when the splitter is destroyed
        self.detachable_widgets.clear()
__init__(parent=None)

Initialize the splitter manager.

Parameters:

Name Type Description Default
parent Optional[QObject]

Parent QObject for ownership.

None
Source code in src\custom_widgets\widget_handlers\splitter_widgets\manager.py
def __init__(self, parent: Optional[QObject] = None) -> None:
    """
    Initialize the splitter manager.

    Args:
        parent: Parent QObject for ownership.
    """
    super().__init__(parent)

    self.delayed_cursors: Dict[QSplitter, DelayedCursorSplitter] = {}
    self.detachable_widgets: List[DetachableWidget] = []
    self.default_cursor_delay: int = 300
add_delayed_cursor(splitter, delay_ms=None)

Add delayed cursor functionality to a splitter.

Parameters:

Name Type Description Default
splitter QSplitter

QSplitter to configure.

required
delay_ms Optional[int]

Delay in milliseconds before cursor change. Uses default_cursor_delay if not specified.

None

Returns:

Type Description
DelayedCursorSplitter

The DelayedCursorSplitter instance managing this splitter.

Source code in src\custom_widgets\widget_handlers\splitter_widgets\manager.py
def add_delayed_cursor(
    self,
    splitter: QSplitter,
    delay_ms: Optional[int] = None,
) -> DelayedCursorSplitter:
    """
    Add delayed cursor functionality to a splitter.

    Args:
        splitter: QSplitter to configure.
        delay_ms: Delay in milliseconds before cursor change.
                  Uses default_cursor_delay if not specified.

    Returns:
        The DelayedCursorSplitter instance managing this splitter.
    """
    if delay_ms is None:
        delay_ms = self.default_cursor_delay

    if splitter not in self.delayed_cursors:
        delayed_cursor = DelayedCursorSplitter(splitter, delay_ms, parent=self)
        self.delayed_cursors[splitter] = delayed_cursor
        return delayed_cursor

    return self.delayed_cursors[splitter]
cleanup()

Clean up all managed splitters and widgets.

Removes event filters from splitters and releases references. Should be called when the parent view is being destroyed.

Source code in src\custom_widgets\widget_handlers\splitter_widgets\manager.py
def cleanup(self) -> None:
    """
    Clean up all managed splitters and widgets.

    Removes event filters from splitters and releases references.
    Should be called when the parent view is being destroyed.
    """
    for delayed_cursor in self.delayed_cursors.values():
        delayed_cursor.cleanup()
    self.delayed_cursors.clear()

    # Note: DetachableWidgets are owned by their parent splitters
    # and will be cleaned up automatically when the splitter is destroyed
    self.detachable_widgets.clear()
create_detachable_widget(content, title='', dialog_stylesheets=None, dialog_module_folder=None)

Create a detachable widget wrapping the given content.

Parameters:

Name Type Description Default
content QWidget

Widget to wrap in detachable container.

required
title str

Title for title bar and detached dialog.

''
dialog_stylesheets Optional[List[str]]

QSS files to apply to detached dialog.

None
dialog_module_folder Optional[str]

Module folder for stylesheet lookup.

None

Returns:

Type Description
DetachableWidget

DetachableWidget instance wrapping the content.

Source code in src\custom_widgets\widget_handlers\splitter_widgets\manager.py
def create_detachable_widget(
    self,
    content: QWidget,
    title: str = "",
    dialog_stylesheets: Optional[List[str]] = None,
    dialog_module_folder: Optional[str] = None,
) -> DetachableWidget:
    """
    Create a detachable widget wrapping the given content.

    Args:
        content: Widget to wrap in detachable container.
        title: Title for title bar and detached dialog.
        dialog_stylesheets: QSS files to apply to detached dialog.
        dialog_module_folder: Module folder for stylesheet lookup.

    Returns:
        DetachableWidget instance wrapping the content.
    """
    detachable = DetachableWidget(
        content,
        title,
        dialog_stylesheets=dialog_stylesheets,
        dialog_module_folder=dialog_module_folder,
    )
    self.detachable_widgets.append(detachable)
    return detachable