"""Shared helpers for draggable control panel layouts."""
from __future__ import annotations
from collections import OrderedDict
from typing import Callable, Iterable
from PyQt6.QtCore import QEvent, QMimeData, QObject, QPoint, QSettings, Qt
from PyQt6.QtGui import QDrag, QPixmap
from PyQt6.QtWidgets import QApplication, QFrame, QPushButton, QSizePolicy, QVBoxLayout, QWidget
[docs]
PANEL_MIME_TYPE = "application/x-magscope-panel"
[docs]
class _TitleDragFilter(QObject):
"""Convert title-area drags into wrapper move operations."""
def __init__(self, wrapper: "PanelWrapper", target: QWidget) -> None:
super().__init__(target)
[docs]
self._wrapper = wrapper
[docs]
self._drag_start = QPoint()
[docs]
def eventFilter(self, obj, event): # type: ignore[override]
if event.type() == QEvent.Type.Enter:
self.target.setCursor(Qt.CursorShape.OpenHandCursor)
elif event.type() == QEvent.Type.Leave:
if not self._dragging:
self.target.unsetCursor()
elif event.type() == QEvent.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton:
self._drag_start = event.position().toPoint()
self._dragging = False
self.target.setCursor(Qt.CursorShape.ClosedHandCursor)
elif event.type() == QEvent.Type.MouseMove and event.buttons() & Qt.MouseButton.LeftButton:
distance = (event.position().toPoint() - self._drag_start).manhattanLength()
if distance >= QApplication.startDragDistance():
if isinstance(self.target, QPushButton):
self.target.setDown(False)
self._dragging = True
self._wrapper.start_drag()
return True
elif event.type() == QEvent.Type.MouseButtonRelease and event.button() == Qt.MouseButton.LeftButton:
self.target.setCursor(Qt.CursorShape.OpenHandCursor)
if self._dragging:
self._dragging = False
return True
return QObject.eventFilter(self, obj, event)
[docs]
def drag_finished(self) -> None:
if self._dragging:
self._dragging = False
self.target.setCursor(Qt.CursorShape.OpenHandCursor)
[docs]
class PanelWrapper(QFrame):
"""Wrap a panel widget and make its title initiate drag-and-drop."""
def __init__(self, manager: "PanelLayoutManager", panel_id: str, widget: QWidget, *, draggable: bool = True) -> None:
super().__init__()
[docs]
self._manager = manager
[docs]
self.panel_id = panel_id
[docs]
self.column: ReorderableColumn | None = None
[docs]
self._drag_filters: list[_TitleDragFilter] = []
[docs]
self.draggable = draggable
[docs]
self._drop_accepted = False
self.setFrameShape(QFrame.Shape.NoFrame)
self.setObjectName(f"PanelWrapper_{panel_id}")
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(widget)
if self.draggable:
self._attach_title_drag()
[docs]
def _attach_title_drag(self) -> None:
groupbox = getattr(self.panel_widget, "groupbox", None)
drag_handle = getattr(groupbox, "drag_handle", None) if groupbox is not None else None
if isinstance(drag_handle, QWidget):
self._register_drag_source(drag_handle)
return
# Fallback for widgets that do not expose a CollapsibleGroupBox title
self._register_drag_source(self.panel_widget)
[docs]
def _register_drag_source(self, widget: QWidget | None) -> None:
if widget is None:
return
for existing in self._drag_filters:
if existing.target is widget:
return
drag_filter = _TitleDragFilter(self, widget)
widget.installEventFilter(drag_filter)
self._drag_filters.append(drag_filter)
[docs]
def start_drag(self) -> None:
if not self.draggable:
return
column = self.column
if column is None:
return
manager = self._manager
manager.notify_drag_started()
try:
drag = QDrag(self)
mime = QMimeData()
mime.setData(PANEL_MIME_TYPE, self.panel_id.encode("utf-8"))
drag.setMimeData(mime)
drag.setHotSpot(QPoint(self.width() // 2, 0))
pixmap = QPixmap(self.size())
pixmap.fill(Qt.GlobalColor.transparent)
self.render(pixmap)
drag.setPixmap(pixmap)
original_index = column.begin_drag(self)
self._drop_accepted = False
result = drag.exec(Qt.DropAction.MoveAction)
if result != Qt.DropAction.MoveAction or not self._drop_accepted:
column.cancel_drag(self, original_index)
finally:
column.finish_drag()
manager.notify_drag_finished()
for drag_filter in self._drag_filters:
drag_filter.drag_finished()
[docs]
def mark_drop_accepted(self) -> None:
self._drop_accepted = True
[docs]
class ReorderableColumn(QWidget):
"""Vertical column of draggable panels with drop support."""
def __init__(self, name: str, pinned_ids: Iterable[str] | None = None) -> None:
super().__init__()
self.setAcceptDrops(True)
[docs]
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setSpacing(6)
self._layout.addStretch(1)
[docs]
self._placeholder: QFrame | None = None
[docs]
self._pinned_ids = set(pinned_ids or ())
[docs]
self._active_drag_height: int | None = None
[docs]
self._manager: PanelLayoutManager | None = None
[docs]
def set_manager(self, manager: "PanelLayoutManager | None") -> None:
self._manager = manager
[docs]
def panels(self) -> list[PanelWrapper]:
widgets: list[PanelWrapper] = []
for index in range(self._layout.count() - 1): # Exclude stretch
item = self._layout.itemAt(index)
widget = item.widget()
if isinstance(widget, PanelWrapper):
widgets.append(widget)
return widgets
[docs]
def panel_ids(self) -> list[str]:
return [wrapper.panel_id for wrapper in self.panels()]
[docs]
def add_panel(self, wrapper: PanelWrapper, index: int | None = None) -> None:
if wrapper.column is self:
current_index = self._layout.indexOf(wrapper)
target_index = self._constrain_index(wrapper, self._target_index(index))
if current_index != -1:
if current_index == target_index:
return
self._layout.removeWidget(wrapper)
target_index = self._constrain_index(wrapper, self._target_index(index))
self._layout.insertWidget(target_index, wrapper)
return
wrapper.setParent(self)
wrapper.column = self
self._layout.insertWidget(target_index, wrapper)
wrapper.show()
return
if wrapper.column is not None:
wrapper.column.remove_panel(wrapper)
wrapper.setParent(self)
wrapper.column = self
constrained_index = self._constrain_index(wrapper, self._target_index(index))
self._layout.insertWidget(constrained_index, wrapper)
wrapper.show()
[docs]
def remove_panel(self, wrapper: PanelWrapper) -> None:
self._layout.removeWidget(wrapper)
wrapper.column = None
wrapper.setParent(None)
wrapper.hide()
[docs]
def clear_panels(self) -> None:
for wrapper in self.panels():
self.remove_panel(wrapper)
[docs]
def begin_drag(self, wrapper: PanelWrapper) -> int:
index = self._layout.indexOf(wrapper)
if index == -1:
return -1
placeholder = self._ensure_placeholder()
height = wrapper.height() or wrapper.sizeHint().height()
height = max(24, height)
self._active_drag_height = height
placeholder.setFixedHeight(height)
self._layout.removeWidget(wrapper)
wrapper.hide()
target_index = min(index, self._layout.count() - 1)
self._layout.insertWidget(target_index, placeholder)
placeholder.show()
return index
[docs]
def cancel_drag(self, wrapper: PanelWrapper, index: int) -> None:
placeholder = self._placeholder
if placeholder is not None:
self._layout.removeWidget(placeholder)
placeholder.hide()
if index < 0:
index = self._layout.count() - 1
target_index = min(index, self._layout.count() - 1)
self._layout.insertWidget(target_index, wrapper)
wrapper.show()
[docs]
def finish_drag(self) -> None:
self._active_drag_height = None
self.clear_placeholder()
[docs]
def _target_index(self, index: int | None) -> int:
stretch_index = self._layout.count() - 1
if index is None or index < 0 or index > stretch_index:
return stretch_index
return min(index, stretch_index)
[docs]
def _drop_index(self, cursor_y: float) -> int:
for i in range(self._layout.count() - 1):
item = self._layout.itemAt(i)
widget = item.widget()
if widget is None:
continue
if cursor_y < widget.y() + widget.height() / 2:
return i
return self._layout.count() - 1
[docs]
def _locked_prefix_length(self) -> int:
count = 0
for i in range(self._layout.count() - 1):
widget = self._layout.itemAt(i).widget()
if isinstance(widget, PanelWrapper) and widget.panel_id in self._pinned_ids:
count += 1
else:
break
return count
[docs]
def _constrain_index(self, wrapper: PanelWrapper, index: int) -> int:
if wrapper.panel_id in self._pinned_ids:
return self._locked_prefix_length()
return max(index, self._locked_prefix_length())
[docs]
def _constrained_drop_index(self, wrapper: PanelWrapper, cursor_y: float) -> int:
return self._constrain_index(wrapper, self._drop_index(cursor_y))
[docs]
def _ensure_placeholder(self) -> QFrame:
if self._placeholder is None:
placeholder = QFrame(self)
placeholder.setObjectName("panel_drop_placeholder")
placeholder.setStyleSheet(
"#panel_drop_placeholder { border: 2px dashed palette(midlight); border-radius: 0px; }"
)
placeholder.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
placeholder.hide()
self._placeholder = placeholder
return self._placeholder
[docs]
def _update_placeholder(self, wrapper: PanelWrapper | None, cursor_y: float) -> None:
if wrapper is None:
self.clear_placeholder()
return
placeholder = self._ensure_placeholder()
if self._active_drag_height is not None:
placeholder.setFixedHeight(self._active_drag_height)
else:
height = wrapper.height() or wrapper.sizeHint().height()
placeholder.setFixedHeight(max(24, height))
target_index = self._constrained_drop_index(wrapper, cursor_y)
current_index = self._layout.indexOf(placeholder)
if current_index == -1:
self._layout.insertWidget(target_index, placeholder)
elif current_index != target_index:
self._layout.removeWidget(placeholder)
stretch_index = self._layout.count() - 1
target_index = min(target_index, stretch_index)
self._layout.insertWidget(target_index, placeholder)
placeholder.show()
[docs]
def clear_placeholder(self) -> None:
if self._placeholder is None:
return
index = self._layout.indexOf(self._placeholder)
if index != -1:
self._layout.removeWidget(self._placeholder)
self._placeholder.hide()
[docs]
def _placeholder_index(self) -> int | None:
if self._placeholder is None:
return None
index = self._layout.indexOf(self._placeholder)
return index if index != -1 else None
[docs]
def dragEnterEvent(self, event) -> None: # type: ignore[override]
if event.mimeData().hasFormat(PANEL_MIME_TYPE):
event.acceptProposedAction()
wrapper = self._wrapper_from_event(event)
if wrapper is not None:
self._update_placeholder(wrapper, event.position().y())
else:
event.ignore()
[docs]
def dragMoveEvent(self, event) -> None: # type: ignore[override]
if event.mimeData().hasFormat(PANEL_MIME_TYPE):
event.acceptProposedAction()
wrapper = self._wrapper_from_event(event)
if wrapper is not None:
self._update_placeholder(wrapper, event.position().y())
else:
event.ignore()
[docs]
def dragLeaveEvent(self, event) -> None: # type: ignore[override]
self.clear_placeholder()
super().dragLeaveEvent(event)
[docs]
def dropEvent(self, event) -> None: # type: ignore[override]
if not event.mimeData().hasFormat(PANEL_MIME_TYPE):
event.ignore()
self.clear_placeholder()
return
manager = self._manager
if manager is None:
event.ignore()
self.clear_placeholder()
return
panel_id = bytes(event.mimeData().data(PANEL_MIME_TYPE)).decode("utf-8")
wrapper = manager.wrapper_for_id(panel_id)
if wrapper is None:
event.ignore()
self.clear_placeholder()
return
drop_index = self._constrained_drop_index(wrapper, event.position().y())
placeholder_index = self._placeholder_index()
if placeholder_index is not None:
drop_index = placeholder_index
self.clear_placeholder()
self.add_panel(wrapper, drop_index)
wrapper.mark_drop_accepted()
manager.layout_changed()
event.acceptProposedAction()
[docs]
def _wrapper_from_event(self, event) -> PanelWrapper | None:
manager = self._manager
if manager is None:
return None
panel_id_bytes = event.mimeData().data(PANEL_MIME_TYPE)
if panel_id_bytes.isEmpty():
return None
panel_id = bytes(panel_id_bytes).decode("utf-8")
return manager.wrapper_for_id(panel_id)
[docs]
class PanelLayoutManager:
"""Coordinate draggable panel columns and persist their layout."""
def __init__(
self,
settings: "QSettings | None",
settings_group: str,
columns: dict[str, ReorderableColumn] | Iterable[tuple[str, ReorderableColumn]],
*,
on_layout_changed: Callable[[dict[str, list[str]]], None] | None = None,
on_drag_active_changed: Callable[[bool], None] | None = None,
) -> None:
[docs]
self._settings: QSettings | None = settings
[docs]
self._settings_group = settings_group
if isinstance(columns, dict):
self.columns: "OrderedDict[str, ReorderableColumn]" = OrderedDict(columns.items())
else:
self.columns = OrderedDict(columns)
for column in self.columns.values():
column.set_manager(self)
[docs]
self._wrappers: dict[str, PanelWrapper] = {}
[docs]
self._default_columns: dict[str, str] = {}
[docs]
self._default_order: list[str] = []
[docs]
self._on_layout_changed = on_layout_changed
[docs]
self._on_drag_active_changed = on_drag_active_changed
[docs]
self._active_drag_count = 0
[docs]
def wrapper_for_id(self, panel_id: str) -> PanelWrapper | None:
return self._wrappers.get(panel_id)
[docs]
def register_panel(self, panel_id: str, widget: QWidget, default_column: str, *, draggable: bool = True) -> PanelWrapper:
if panel_id in self._wrappers:
raise ValueError(f"Panel '{panel_id}' already registered")
if default_column not in self.columns:
raise ValueError(f"Unknown column '{default_column}'")
wrapper = PanelWrapper(self, panel_id, widget, draggable=draggable)
self._wrappers[panel_id] = wrapper
self._default_columns[panel_id] = default_column
self._default_order.append(panel_id)
return wrapper
[docs]
def restore_layout(self) -> None:
layout: "OrderedDict[str, list[str]]" = OrderedDict((name, []) for name in self.columns)
used: set[str] = set()
stored = self._load_layout()
for name, panel_ids in stored.items():
if name not in layout:
continue
for panel_id in panel_ids:
if panel_id in self._wrappers and panel_id not in used:
layout[name].append(panel_id)
used.add(panel_id)
for panel_id in self._default_order:
default_column = self._default_columns[panel_id]
if panel_id in used:
continue
column_name = default_column if default_column in layout else next(iter(layout))
layout[column_name].append(panel_id)
used.add(panel_id)
for column in self.columns.values():
column.clear_placeholder()
column.clear_panels()
for column_name, panel_ids in layout.items():
column = self.columns[column_name]
for panel_id in panel_ids:
wrapper = self._wrappers.get(panel_id)
if wrapper is not None:
column.add_panel(wrapper)
[docs]
def save_layout(self) -> None:
if self._settings is None:
return
self._settings.beginGroup(self._settings_group)
order_key = "__column_order__"
column_names = list(self.columns.keys())
self._settings.setValue(order_key, column_names)
stored_keys = set(self._settings.childKeys())
for name, column in self.columns.items():
self._settings.setValue(name, column.panel_ids())
stored_keys.discard(name)
stored_keys.discard(order_key)
for obsolete in stored_keys:
self._settings.remove(obsolete)
self._settings.endGroup()
[docs]
def current_layout(self) -> dict[str, list[str]]:
return {name: column.panel_ids() for name, column in self.columns.items()}
[docs]
def layout_changed(self) -> None:
self.save_layout()
if self._on_layout_changed is not None:
self._on_layout_changed(self.current_layout())
[docs]
def notify_drag_started(self) -> None:
self._active_drag_count += 1
if self._active_drag_count == 1 and self._on_drag_active_changed is not None:
self._on_drag_active_changed(True)
[docs]
def notify_drag_finished(self) -> None:
if self._active_drag_count > 0:
self._active_drag_count -= 1
if self._active_drag_count == 0 and self._on_drag_active_changed is not None:
self._on_drag_active_changed(False)
[docs]
def _normalise_panel_list(self, stored) -> list[str] | None:
if isinstance(stored, list):
return [str(item) for item in stored]
if isinstance(stored, str) and stored:
return [item.strip() for item in stored.split(",") if item.strip()]
return None
[docs]
def _load_layout(self) -> "OrderedDict[str, list[str]]":
layout: "OrderedDict[str, list[str]]" = OrderedDict()
if self._settings is None:
return layout
self._settings.beginGroup(self._settings_group)
order_key = "__column_order__"
order_value = self._settings.value(order_key, defaultValue=[])
column_order: list[str]
if isinstance(order_value, list):
column_order = [str(item) for item in order_value]
elif isinstance(order_value, str) and order_value:
column_order = [item.strip() for item in order_value.split(",") if item.strip()]
else:
column_order = []
seen: set[str] = set()
for name in column_order:
stored = self._settings.value(name, defaultValue=None)
panel_ids = self._normalise_panel_list(stored)
if panel_ids is not None:
layout[name] = panel_ids
seen.add(name)
for name in self._settings.childKeys():
name = str(name)
if name == order_key or name in seen:
continue
stored = self._settings.value(name, defaultValue=None)
panel_ids = self._normalise_panel_list(stored)
if panel_ids is not None:
layout[name] = panel_ids
self._settings.endGroup()
return layout
[docs]
def stored_layout(self) -> "OrderedDict[str, list[str]]":
return self._load_layout()
[docs]
def stored_column_names(self) -> list[str]:
stored = self._load_layout()
return list(stored.keys())
[docs]
def add_column(self, name: str, column: ReorderableColumn, index: int | None = None) -> None:
if name in self.columns:
raise ValueError(f"Column '{name}' already exists")
items = list(self.columns.items())
target_index = len(items) if index is None else max(0, min(index, len(items)))
items.insert(target_index, (name, column))
self.columns = OrderedDict(items)
column.set_manager(self)
[docs]
def remove_column(self, name: str) -> None:
column = self.columns.get(name)
if column is None:
return
if column.panels():
raise ValueError(f"Column '{name}' is not empty")
column.set_manager(None)
del self.columns[name]
if self._settings is None:
return
self._settings.beginGroup(self._settings_group)
self._settings.remove(name)
order_key = "__column_order__"
order_value = self._settings.value(order_key, defaultValue=[])
if isinstance(order_value, list):
updated_order = [str(item) for item in order_value if str(item) != name]
elif isinstance(order_value, str) and order_value:
updated_order = [item.strip() for item in order_value.split(",") if item.strip() and item.strip() != name]
else:
updated_order = []
self._settings.setValue(order_key, updated_order)
self._settings.endGroup()