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.

On Windows, the native titlebar is replaced with a custom frameless titlebar for a cleaner appearance while preserving resize and snap.

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.

    On Windows, the native titlebar is replaced with a custom frameless
    titlebar for a cleaner appearance while preserving resize and snap.

    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[PathDef]] = None,
        dialog_module_folder: Optional[str] = None,
        dialog_titlebar: Optional[QWidget] = 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.
            dialog_titlebar: Optional widget from the content that should
                serve as the frameless drag area. When provided, no
                DialogTitlebarWidget is created — the content's own
                header bar is used directly.
        """
        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)

        # Frameless titlebar (Windows only)
        self._frameless_helper = None
        self._dialog_titlebar = None
        self._external_titlebar = dialog_titlebar
        import sys
        if sys.platform == "win32":
            self._setup_frameless(title, dialog_titlebar)

        # Apply stylesheets if provided
        self._apply_dialog_stylesheets(dialog_stylesheets)

    def _setup_frameless(
        self, title: str, external_titlebar: Optional[QWidget]
    ) -> None:
        """Set up frameless window behavior on Windows.

        If an external_titlebar is provided, it is used as the drag area
        directly. Otherwise a DialogTitlebarWidget is created and inserted
        at the top of the layout.
        """
        from src.shared_services.rendering.frameless.frameless_window_helper import (
            FramelessWindowHelper,
        )

        if external_titlebar is not None:
            # Use the content widget's own header bar as the drag area
            self._frameless_helper = FramelessWindowHelper(
                self, external_titlebar
            )
            self._frameless_helper.attach()
        else:
            from src.shared_services.rendering.frameless.dialog_titlebar_widget import (
                DialogTitlebarWidget,
            )
            self._dialog_titlebar = DialogTitlebarWidget(title, self)
            self._dialog_titlebar.close_requested.connect(self.close)
            self._layout.insertWidget(0, self._dialog_titlebar)

            self._frameless_helper = FramelessWindowHelper(
                self, self._dialog_titlebar
            )
            self._frameless_helper.attach()

    def _set_window_icon(self) -> None:
        """Set the window icon for the dialog."""
        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)))

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

        Includes the frameless titlebar stylesheet on Windows so the
        dialog titlebar is styled correctly.

        Args:
            dialog_stylesheets: List of QSS filenames.
        """
        from src.shared_services.rendering.stylesheets.stylesheet_manager import (
            StylesheetManager,
        )

        sheets = list(dialog_stylesheets) if dialog_stylesheets else []

        # Include titlebar stylesheet when frameless is active
        if self._frameless_helper is not None:
            from src.main_hub.orchestration.constants.paths import Stylesheets
            sheets.append(Stylesheets.TitlebarQSS)

        manager = StylesheetManager.instance()
        manager.register(self, sheets if sheets else None)

    def nativeEvent(self, event_type, message):
        """Forward native Windows events to the frameless helper."""
        if self._frameless_helper is not None:
            result = self._frameless_helper.handle_native_event(event_type, message)
            if result is not None:
                return result
        return super().nativeEvent(event_type, message)

    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)
    @staticmethod
    def _calculate_safe_position(
        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._on_first_show)

    def _on_first_show(self) -> None:
        """Post-show setup: position check and clear auto-focus."""
        self.ensure_dialog_on_screen()
        # Prevent Qt from giving focus to the first child widget,
        # which causes an unwanted focus border on toolbar buttons.
        self.setFocus()

    @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, dialog_titlebar=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[PathDef]]

Optional list of QSS filenames to apply.

None
dialog_module_folder Optional[str]

Module folder for stylesheet lookup.

None
dialog_titlebar Optional[QWidget]

Optional widget from the content that should serve as the frameless drag area. When provided, no DialogTitlebarWidget is created — the content's own header bar is used directly.

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[PathDef]] = None,
    dialog_module_folder: Optional[str] = None,
    dialog_titlebar: Optional[QWidget] = 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.
        dialog_titlebar: Optional widget from the content that should
            serve as the frameless drag area. When provided, no
            DialogTitlebarWidget is created — the content's own
            header bar is used directly.
    """
    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)

    # Frameless titlebar (Windows only)
    self._frameless_helper = None
    self._dialog_titlebar = None
    self._external_titlebar = dialog_titlebar
    import sys
    if sys.platform == "win32":
        self._setup_frameless(title, dialog_titlebar)

    # Apply stylesheets if provided
    self._apply_dialog_stylesheets(dialog_stylesheets)
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)
nativeEvent(event_type, message)

Forward native Windows events to the frameless helper.

Source code in src\custom_widgets\widget_handlers\splitter_widgets\detachable_widgets.py
def nativeEvent(self, event_type, message):
    """Forward native Windows events to the frameless helper."""
    if self._frameless_helper is not None:
        result = self._frameless_helper.handle_native_event(event_type, message)
        if result is not None:
            return result
    return super().nativeEvent(event_type, message)
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._on_first_show)

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."""
        from src.shared_services.rendering.icons.icon_paths import Icons
        from src.shared_services.rendering.icons.icon_registry import IconRegistry
        from src.shared_services.rendering.icons.colors import IconColors

        self.detach_button.setIconSize(QSize(18, 18))
        IconRegistry.instance().register(
            self.detach_button, Icons.Toggle.DetachDialog, size=18,
            color=IconColors.Primary,
        )
__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.

A task bar item is added to the shared taskbar frame (if set) when detached, and removed when reattached.

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
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
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.

    A task bar item is added to the shared taskbar frame (if set) when
    detached, and removed when reattached.

    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)
    """

    detached = Signal()
    reattached = Signal()

    # Shared taskbar frame for all DetachableWidget instances
    _taskbar_frame: Optional[QFrame] = None
    _taskbar_info_label: Optional[QLabel] = None

    @classmethod
    def set_taskbar_frame(cls, frame: QFrame, info_label: Optional[QLabel] = None) -> None:
        """Set the shared taskbar frame where task bar items are shown.

        Called once from the main view to connect the cwCTaskBarFrame.
        The info_label is hidden when no dialogs are detached.
        """
        cls._taskbar_frame = frame
        cls._taskbar_info_label = info_label
        if info_label is not None:
            info_label.setVisible(False)

    def __init__(
        self,
        content_widget: QWidget,
        title: str = "",
        dialog_stylesheets: Optional[List[PathDef]] = None,
        dialog_module_folder: Optional[str] = None,
        detach_button: Optional[QPushButton] = None,
        dialog_titlebar: Optional[QWidget] = 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.
            detach_button: Optional external button that triggers detach.
                When provided, no built-in title bar is created -- the
                content widget is shown directly and the external button
                controls the detach action.
            dialog_titlebar: Optional widget from the content that should
                serve as the frameless drag area when detached. When
                provided, the content's own header bar is used instead
                of a separate DialogTitlebarWidget. The detach_button's
                icon is swapped to a reattach icon while detached.
            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
        self._external_detach_button = detach_button
        self._dialog_titlebar = dialog_titlebar

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

        # Task bar item (created on detach, removed on reattach)
        self._taskbar_item = None
        # Store the initial dialog size for expand-and-focus restore
        self._detach_size: Optional[QSize] = None
        # Title label injected into external titlebar while detached
        self._titlebar_label: Optional[QLabel] = None
        if self._dialog_titlebar is not None:
            self._titlebar_label = QLabel(self.title, self._dialog_titlebar)
            self._titlebar_label.setObjectName("detachedTitleLabel")
            # Insert after the first widget (icon) in the header bar layout
            titlebar_layout = self._dialog_titlebar.layout()
            if titlebar_layout is not None:
                titlebar_layout.insertWidget(1, self._titlebar_label)
            self._titlebar_label.setVisible(False)

        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)

        if self._external_detach_button is not None:
            # External button mode: no title bar, content directly in layout
            self.title_bar = None
            self._external_detach_button.clicked.connect(self.detach)

            content_container = QWidget()
            content_container.setObjectName("DetachableWidget_ContentContainer")
            content_layout = QVBoxLayout()
            content_layout.setContentsMargins(0, 0, 0, 0)
            content_layout.addWidget(self.content_widget)
            content_container.setLayout(content_layout)

            layout.addWidget(content_container)
        else:
            # Built-in title bar mode (default)
            self.title_bar = DetachableTitleBar(self.title)
            self.title_bar.detach_requested.connect(self.detach)

            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)

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

        self.setLayout(layout)

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

        manager = StylesheetManager.instance()
        manager.register(self, [WidgetStylesheets.DetachableWidget])

    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.
        """
        from src.shared_services.rendering.icons.icon_paths import Icons
        from src.shared_services.rendering.icons.icon_registry import IconRegistry
        from src.shared_services.rendering.icons.colors import IconColors

        button.setIconSize(QSize(14, 14))
        IconRegistry.instance().register(
            button, Icons.Toggle.ReattachDialog, size=14,
            color=IconColors.Primary,
        )

    def _set_focus_button_icon(self, button: QPushButton) -> None:
        """Set the icon for the focus-dialog button."""
        from src.shared_services.rendering.icons.icon_paths import Icons
        from src.shared_services.rendering.icons.icon_registry import IconRegistry
        from src.shared_services.rendering.icons.colors import IconColors

        button.setIconSize(QSize(14, 14))
        IconRegistry.instance().register(
            button, Icons.Action.Visibility, size=14,
            color=IconColors.Primary,
        )

    def _swap_detach_button_to_reattach(self) -> None:
        """Swap the external detach button to reattach mode.

        Changes the icon and reconnects the click signal so the button
        closes the dialog (triggering reattach) instead of detaching.
        Shows the title label in the header bar.
        """
        from src.shared_services.rendering.icons.icon_paths import Icons
        from src.shared_services.rendering.icons.icon_registry import IconRegistry
        from src.shared_services.rendering.icons.colors import IconColors

        btn = self._external_detach_button
        btn.clicked.disconnect()
        btn.clicked.connect(self.dialog.close)

        registry = IconRegistry.instance()
        registry.unregister(btn)
        registry.register(
            btn, Icons.Toggle.ReattachDialog, size=18,
            color=IconColors.Primary,
        )

        if self._titlebar_label is not None:
            self._titlebar_label.setVisible(True)

    def _swap_detach_button_to_detach(self) -> None:
        """Restore the external detach button to its original detach mode.

        Hides the title label in the header bar.
        """
        from src.shared_services.rendering.icons.icon_paths import Icons
        from src.shared_services.rendering.icons.icon_registry import IconRegistry
        from src.shared_services.rendering.icons.colors import IconColors

        btn = self._external_detach_button
        try:
            btn.clicked.disconnect()
        except RuntimeError:
            pass
        btn.clicked.connect(self.detach)

        registry = IconRegistry.instance()
        registry.unregister(btn)
        registry.register(
            btn, Icons.Toggle.DetachDialog, size=18,
            color=IconColors.Primary,
        )

        if self._titlebar_label is not None:
            self._titlebar_label.setVisible(False)

    def _focus_dialog(self) -> None:
        """Bring the detached dialog to the front.

        If the dialog is minimized, restores it to its detach size and
        re-centers it on screen. Otherwise just raises and activates it.
        """
        if self.dialog is None:
            return

        try:
            if self.dialog.isMinimized():
                self.dialog.showNormal()
                if self._detach_size is not None:
                    self.dialog.resize(self._detach_size)
                self.dialog.ensure_dialog_on_screen()
            self.dialog.raise_()
            self.dialog.activateWindow()
        except RuntimeError:
            pass

    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 -- adapt to splitter orientation
        self.placeholder = QWidget()
        self.placeholder.setObjectName("DetachableWidget_Placeholder")

        is_horizontal = (
            self.parent_splitter.orientation() == Qt.Orientation.Horizontal
        )

        # Reattach button
        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)

        # Focus-dialog button
        focus_button = QPushButton()
        focus_button.setObjectName("DetachableWidget_FocusButton")
        focus_button.setFixedSize(20, 20)
        focus_button.setCursor(Qt.CursorShape.PointingHandCursor)
        self._set_focus_button_icon(focus_button)
        focus_button.clicked.connect(self._focus_dialog)

        if is_horizontal:
            # Horizontal splitter: thin vertical strip, buttons at top
            placeholder_layout = QVBoxLayout()
            placeholder_layout.setContentsMargins(4, 6, 4, 6)
            placeholder_layout.setSpacing(4)

            placeholder_layout.addWidget(
                reattach_button, 0, Qt.AlignmentFlag.AlignHCenter
            )
            placeholder_layout.addWidget(
                focus_button, 0, Qt.AlignmentFlag.AlignHCenter
            )

            # Vertical text label
            placeholder_label = _VerticalLabel(self.title)

            placeholder_layout.addWidget(
                placeholder_label, 1, Qt.AlignmentFlag.AlignHCenter
            )
            placeholder_layout.addStretch()

            self.placeholder.setLayout(placeholder_layout)
            self.placeholder.setMinimumWidth(28)
            self.placeholder.setMaximumWidth(32)
            self.placeholder.setSizePolicy(
                QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding
            )
        else:
            # Vertical splitter: thin horizontal bar (original behaviour)
            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)

            placeholder_layout.addWidget(placeholder_label)
            placeholder_layout.addWidget(focus_button)
            placeholder_layout.addWidget(reattach_button)

            self.placeholder.setLayout(placeholder_layout)
            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)

        # Collapse the placeholder to its max size and redistribute freed space
        sizes = list(self.saved_sizes)
        if self.saved_index < len(sizes):
            if is_horizontal:
                placeholder_size = self.placeholder.maximumWidth()
            else:
                placeholder_size = self.placeholder.maximumHeight()
            freed = sizes[self.saved_index] - placeholder_size
            sizes[self.saved_index] = placeholder_size
            # Distribute freed space to other panels proportionally
            others_total = sum(
                s for i, s in enumerate(sizes) if i != self.saved_index
            )
            if others_total > 0 and freed > 0:
                for i in range(len(sizes)):
                    if i != self.saved_index:
                        sizes[i] += int(freed * sizes[i] / others_total)
            self.parent_splitter.setSizes(sizes)

        # Create dialog
        self.dialog = DetachableDialog(
            self.title,
            self.window(),
            dialog_stylesheets=self.dialog_stylesheets,
            dialog_module_folder=self.dialog_module_folder,
            dialog_titlebar=self._dialog_titlebar,
        )
        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._detach_size = QSize(dialog_width, dialog_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()

        # Swap external detach button to reattach mode
        if self._external_detach_button is not None:
            if self._dialog_titlebar is not None:
                # Content's header bar is the titlebar — repurpose the
                # detach button as a close/reattach button
                self._swap_detach_button_to_reattach()
            else:
                self._external_detach_button.setVisible(False)

        # Hide this widget
        self.hide()

        # Add task bar item to the shared taskbar frame
        self._add_taskbar_item()

        self.detached.emit()

    def reattach(self) -> None:
        """Reattach the widget to its original splitter position."""
        # Remove task bar item first
        self._remove_taskbar_item()

        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 is at index 0 (external button) or 1 (title bar)
        container_index = 0 if self.title_bar is None else 1
        content_container = self.layout().itemAt(container_index).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

        # Restore external detach button
        if self._external_detach_button is not None:
            if self._dialog_titlebar is not None:
                self._swap_detach_button_to_detach()
            else:
                self._external_detach_button.setVisible(True)

        # Show this widget
        self.show()

        self.reattached.emit()

    # -- Taskbar integration ----------------------------------------------------

    def _add_taskbar_item(self) -> None:
        """Create a task bar item in the shared taskbar frame."""
        if self._taskbar_frame is None:
            return

        from src.custom_widgets.widget_handlers.splitter_widgets.detach_taskbar_widget.detach_task_bar_item import (
            DetachTaskBarItemWidget,
        )

        self._taskbar_item = DetachTaskBarItemWidget(self.title)
        self._taskbar_item.focus_requested.connect(self._focus_dialog)
        self._taskbar_item.close_requested.connect(self.reattach)

        # Insert before the spacer (last item in the layout)
        layout = self._taskbar_frame.layout()
        if layout is not None:
            spacer_index = layout.count() - 1
            layout.insertWidget(max(0, spacer_index), self._taskbar_item)

        if self._taskbar_info_label is not None:
            self._taskbar_info_label.setVisible(True)

    def _remove_taskbar_item(self) -> None:
        """Remove the task bar item from the shared taskbar frame."""
        if self._taskbar_item is not None:
            try:
                self._taskbar_item.setParent(None)
                self._taskbar_item.deleteLater()
            except RuntimeError:
                pass
            self._taskbar_item = None

        self._update_info_label_visibility()

    @classmethod
    def _update_info_label_visibility(cls) -> None:
        """Hide the info label if no task bar items remain in the frame."""
        if cls._taskbar_info_label is None or cls._taskbar_frame is None:
            return

        from src.custom_widgets.widget_handlers.splitter_widgets.detach_taskbar_widget.detach_task_bar_item import (
            DetachTaskBarItemWidget,
        )

        layout = cls._taskbar_frame.layout()
        if layout is None:
            return

        has_items = any(
            isinstance(layout.itemAt(i).widget(), DetachTaskBarItemWidget)
            for i in range(layout.count())
            if layout.itemAt(i).widget() is not None
        )
        cls._taskbar_info_label.setVisible(has_items)

    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, detach_button=None, dialog_titlebar=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[PathDef]]

QSS files to apply to detached dialog.

None
dialog_module_folder Optional[str]

Module folder for stylesheet lookup.

None
detach_button Optional[QPushButton]

Optional external button that triggers detach. When provided, no built-in title bar is created -- the content widget is shown directly and the external button controls the detach action.

None
dialog_titlebar Optional[QWidget]

Optional widget from the content that should serve as the frameless drag area when detached. When provided, the content's own header bar is used instead of a separate DialogTitlebarWidget. The detach_button's icon is swapped to a reattach icon while detached.

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[PathDef]] = None,
    dialog_module_folder: Optional[str] = None,
    detach_button: Optional[QPushButton] = None,
    dialog_titlebar: Optional[QWidget] = 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.
        detach_button: Optional external button that triggers detach.
            When provided, no built-in title bar is created -- the
            content widget is shown directly and the external button
            controls the detach action.
        dialog_titlebar: Optional widget from the content that should
            serve as the frameless drag area when detached. When
            provided, the content's own header bar is used instead
            of a separate DialogTitlebarWidget. The detach_button's
            icon is swapped to a reattach icon while detached.
        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
    self._external_detach_button = detach_button
    self._dialog_titlebar = dialog_titlebar

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

    # Task bar item (created on detach, removed on reattach)
    self._taskbar_item = None
    # Store the initial dialog size for expand-and-focus restore
    self._detach_size: Optional[QSize] = None
    # Title label injected into external titlebar while detached
    self._titlebar_label: Optional[QLabel] = None
    if self._dialog_titlebar is not None:
        self._titlebar_label = QLabel(self.title, self._dialog_titlebar)
        self._titlebar_label.setObjectName("detachedTitleLabel")
        # Insert after the first widget (icon) in the header bar layout
        titlebar_layout = self._dialog_titlebar.layout()
        if titlebar_layout is not None:
            titlebar_layout.insertWidget(1, self._titlebar_label)
        self._titlebar_label.setVisible(False)

    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 -- adapt to splitter orientation
    self.placeholder = QWidget()
    self.placeholder.setObjectName("DetachableWidget_Placeholder")

    is_horizontal = (
        self.parent_splitter.orientation() == Qt.Orientation.Horizontal
    )

    # Reattach button
    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)

    # Focus-dialog button
    focus_button = QPushButton()
    focus_button.setObjectName("DetachableWidget_FocusButton")
    focus_button.setFixedSize(20, 20)
    focus_button.setCursor(Qt.CursorShape.PointingHandCursor)
    self._set_focus_button_icon(focus_button)
    focus_button.clicked.connect(self._focus_dialog)

    if is_horizontal:
        # Horizontal splitter: thin vertical strip, buttons at top
        placeholder_layout = QVBoxLayout()
        placeholder_layout.setContentsMargins(4, 6, 4, 6)
        placeholder_layout.setSpacing(4)

        placeholder_layout.addWidget(
            reattach_button, 0, Qt.AlignmentFlag.AlignHCenter
        )
        placeholder_layout.addWidget(
            focus_button, 0, Qt.AlignmentFlag.AlignHCenter
        )

        # Vertical text label
        placeholder_label = _VerticalLabel(self.title)

        placeholder_layout.addWidget(
            placeholder_label, 1, Qt.AlignmentFlag.AlignHCenter
        )
        placeholder_layout.addStretch()

        self.placeholder.setLayout(placeholder_layout)
        self.placeholder.setMinimumWidth(28)
        self.placeholder.setMaximumWidth(32)
        self.placeholder.setSizePolicy(
            QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding
        )
    else:
        # Vertical splitter: thin horizontal bar (original behaviour)
        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)

        placeholder_layout.addWidget(placeholder_label)
        placeholder_layout.addWidget(focus_button)
        placeholder_layout.addWidget(reattach_button)

        self.placeholder.setLayout(placeholder_layout)
        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)

    # Collapse the placeholder to its max size and redistribute freed space
    sizes = list(self.saved_sizes)
    if self.saved_index < len(sizes):
        if is_horizontal:
            placeholder_size = self.placeholder.maximumWidth()
        else:
            placeholder_size = self.placeholder.maximumHeight()
        freed = sizes[self.saved_index] - placeholder_size
        sizes[self.saved_index] = placeholder_size
        # Distribute freed space to other panels proportionally
        others_total = sum(
            s for i, s in enumerate(sizes) if i != self.saved_index
        )
        if others_total > 0 and freed > 0:
            for i in range(len(sizes)):
                if i != self.saved_index:
                    sizes[i] += int(freed * sizes[i] / others_total)
        self.parent_splitter.setSizes(sizes)

    # Create dialog
    self.dialog = DetachableDialog(
        self.title,
        self.window(),
        dialog_stylesheets=self.dialog_stylesheets,
        dialog_module_folder=self.dialog_module_folder,
        dialog_titlebar=self._dialog_titlebar,
    )
    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._detach_size = QSize(dialog_width, dialog_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()

    # Swap external detach button to reattach mode
    if self._external_detach_button is not None:
        if self._dialog_titlebar is not None:
            # Content's header bar is the titlebar — repurpose the
            # detach button as a close/reattach button
            self._swap_detach_button_to_reattach()
        else:
            self._external_detach_button.setVisible(False)

    # Hide this widget
    self.hide()

    # Add task bar item to the shared taskbar frame
    self._add_taskbar_item()

    self.detached.emit()
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."""
    # Remove task bar item first
    self._remove_taskbar_item()

    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 is at index 0 (external button) or 1 (title bar)
    container_index = 0 if self.title_bar is None else 1
    content_container = self.layout().itemAt(container_index).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

    # Restore external detach button
    if self._external_detach_button is not None:
        if self._dialog_titlebar is not None:
            self._swap_detach_button_to_detach()
        else:
            self._external_detach_button.setVisible(True)

    # Show this widget
    self.show()

    self.reattached.emit()
set_taskbar_frame(frame, info_label=None) classmethod

Set the shared taskbar frame where task bar items are shown.

Called once from the main view to connect the cwCTaskBarFrame. The info_label is hidden when no dialogs are detached.

Source code in src\custom_widgets\widget_handlers\splitter_widgets\detachable_widgets.py
@classmethod
def set_taskbar_frame(cls, frame: QFrame, info_label: Optional[QLabel] = None) -> None:
    """Set the shared taskbar frame where task bar items are shown.

    Called once from the main view to connect the cwCTaskBarFrame.
    The info_label is hidden when no dialogs are detached.
    """
    cls._taskbar_frame = frame
    cls._taskbar_info_label = info_label
    if info_label is not None:
        info_label.setVisible(False)

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