""""
Miscellaneous custom Qt widgets for the GUI
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from PyQt6.QtCore import (QEasingCurve, QMimeData, QPoint, QPointF, QPropertyAnimation, QRect,
QRectF, QSettings, Qt, QTimer, pyqtSignal)
from PyQt6.QtGui import QBrush, QColor, QDrag, QFont, QPainter, QPalette, QPen, QValidator
from PyQt6.QtWidgets import (QCheckBox, QFrame, QGraphicsItem, QGraphicsRectItem,
QGraphicsSimpleTextItem, QGroupBox, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QScrollArea, QSizePolicy, QSplitter,
QSplitterHandle, QVBoxLayout, QWidget)
if TYPE_CHECKING:
from magscope.ui.ui import UIManager
[docs]
class LabeledLineEditWithValue(QWidget):
"""Horizontally combined QLabel, QLineedit, and a second QLabel to show the value."""
def __init__(self,
*,
label_text: str,
validator: QValidator = None,
widths: tuple[int, int, int] = (0, 0, 0),
default=None,
callback: callable = None):
super().__init__()
# Layout
[docs]
self.layout = QHBoxLayout()
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(4)
self.setLayout(self.layout)
# Label
[docs]
self.label = QLabel(label_text)
self.label.setWordWrap(True)
self.label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
self.label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.MinimumExpanding)
self.label.setToolTip(label_text)
if widths[0] > 0:
self.label.setMaximumWidth(widths[0])
self.layout.addWidget(self.label)
# Lineedit
[docs]
self.lineedit = QLineEdit()
if validator:
self.lineedit.setValidator(validator)
if callback:
self.lineedit.editingFinished.connect(callback) # type: ignore
if widths[1] > 0:
self.lineedit.setFixedWidth(widths[1])
self.layout.addWidget(self.lineedit)
# Value Label
[docs]
self.value_label = QLabel(default)
if widths[2] > 0:
self.value_label.setFixedWidth(widths[2])
self.layout.addWidget(self.value_label)
[docs]
class LabeledLineEdit(QWidget):
"""Horizontally combined QLabel and QLineedit."""
def __init__(self,
*,
label_text: str,
widths: tuple[int, int] = (0, 0),
default=None,
validator: QValidator = None,
callback: callable = None):
super().__init__()
# Layout
[docs]
self.layout = QHBoxLayout()
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(4)
self.setLayout(self.layout)
# Label
[docs]
self.label = QLabel(label_text)
if widths[0] > 0:
self.label.setFixedWidth(widths[0])
self.layout.addWidget(self.label)
# Lineedit
[docs]
self.lineedit = QLineEdit(default)
if validator:
self.lineedit.setValidator(validator)
if callback:
self.lineedit.textChanged.connect(callback) # type: ignore
if widths[1] > 0:
self.lineedit.setFixedWidth(widths[1])
self.layout.addWidget(self.lineedit)
[docs]
class LabeledCheckbox(QWidget):
"""Horizontally combined QLabel and QCheckbox."""
def __init__(self,
*,
label_text: str,
widths: tuple[int, int] = (0, 0),
default=False,
callback: callable = None):
super().__init__()
# Layout
[docs]
self.layout = QHBoxLayout()
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(4)
self.setLayout(self.layout)
# Label
[docs]
self.label = QLabel(label_text)
if widths[0] > 0:
self.label.setFixedWidth(widths[0])
self.layout.addWidget(self.label)
# Checkbox
[docs]
self.checkbox = QCheckBox()
self.checkbox.setChecked(default)
if callback:
self.checkbox.toggled.connect(callback) # type: ignore
if widths[1] > 0:
self.checkbox.setFixedWidth(widths[1])
self.checkbox.setMinimumWidth(20)
self.layout.addWidget(self.checkbox, alignment=Qt.AlignmentFlag.AlignLeft)
self.layout.addStretch(1)
[docs]
class LabeledStepperLineEdit(QWidget):
"""Horizontally combined QLabel and QLineedit with a QButton to increment/decrement the value on either side."""
def __init__(self,
*,
label_text: str,
left_button_text: str,
right_button_text: str,
widths: tuple[int, int, int, int] = (0, 0, 0, 0),
default=None,
validator: QValidator = None,
callbacks: tuple[callable, callable,
callable] = (None, None, None)):
super().__init__()
# Layout
[docs]
self.layout = QHBoxLayout()
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(4)
self.setLayout(self.layout)
# Label
[docs]
self.name_label = QLabel(label_text)
if widths[0] > 0:
self.name_label.setFixedWidth(widths[0])
self.layout.addWidget(self.name_label)
# Left Button
self.left_button.clicked.connect(callbacks[0]) # type: ignore
self.layout.addWidget(self.left_button)
# Lineedit
[docs]
self.lineedit = QLineEdit(default)
if validator:
self.lineedit.setValidator(validator)
if callbacks[1]:
self.lineedit.editingFinished.connect(callbacks[1]) # type: ignore
if widths[2] > 0:
self.lineedit.setFixedWidth(widths[2])
self.layout.addWidget(self.lineedit)
# Right Button
self.right_button.clicked.connect(callbacks[2]) # type: ignore
self.layout.addWidget(self.right_button)
[docs]
class CollapsibleGroupBox(QGroupBox):
"""A collapsible QGroupBox with the title text as a toggle button to show/hide its content"""
def __init__(self, title="", collapsed=False):
super().__init__()
[docs]
self.default_collapsed = collapsed
[docs]
self._settings_key = f"{self.title}_Group Box Collapsed"
# Retrieve last collapse state
settings = QSettings('MagScope', 'MagScope')
collapsed = settings.value(self._settings_key, collapsed, type=bool)
# Set up the toggle button (will be the groupbox's title)
self.toggle_button.setCheckable(True)
self.toggle_button.setChecked(not collapsed)
self.toggle_button.setStyleSheet("""
text-align: left;
padding: 0px;
border: none;
font-weight: bold;
font-size: 14px;
""")
self.toggle_button.toggled.connect(self.toggle) # type: ignore
# Replace the groupbox's default title with the button
self.setLayout(QVBoxLayout())
self.layout().setContentsMargins(0, 0, 0, 2)
title_widget = QWidget()
title_layout = QHBoxLayout(title_widget)
title_layout.setContentsMargins(4, 4, 4, 4)
title_layout.setSpacing(6)
self.toggle_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
title_layout.addWidget(self.toggle_button)
[docs]
self.drag_handle = QLabel("᎒᎒᎒")
self.drag_handle.setObjectName("PanelDragHandle")
self.drag_handle.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.drag_handle.setCursor(Qt.CursorShape.OpenHandCursor)
self.drag_handle.setToolTip("Drag to reposition panel")
self.drag_handle.setFixedWidth(20)
self.drag_handle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.drag_handle.setStyleSheet("font-size: 16px;")
title_layout.addWidget(self.drag_handle)
self.setTitle("")
self.layout().addWidget(title_widget)
self.layout().setSpacing(0)
# Content area
[docs]
self.content_area = QWidget()
self.content_area.setSizePolicy(QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Fixed)
self.layout().addWidget(self.content_area)
# Animation
[docs]
self.animation = QPropertyAnimation(self.content_area,
b'maximumHeight')
self.animation.setDuration(300)
self.animation.setEasingCurve(QEasingCurve.Type.InOutQuad)
self.animation.finished.connect(self._animation_finished)
# Start collapsed
[docs]
self.collapsed = collapsed
if collapsed:
self.content_area.setMaximumHeight(0)
else:
self.content_area.setMaximumHeight(16777215) # QT default maximum
[docs]
def _animation_finished(self) -> None:
if self.collapsed:
self.content_area.setMaximumHeight(0)
else:
self.content_area.setMaximumHeight(16777215)
@property
[docs]
def settings_key(self) -> str:
return self._settings_key
[docs]
def toggle(self, checked):
self._apply_collapsed_state(not checked, animate=True, persist=True)
[docs]
def reset_to_default(self) -> None:
self._apply_collapsed_state(self.default_collapsed, animate=False, persist=True)
[docs]
def _apply_collapsed_state(self, collapsed: bool, *, animate: bool, persist: bool) -> None:
expanded = not collapsed
self.collapsed = collapsed
self.toggle_button.blockSignals(True)
self.toggle_button.setChecked(expanded)
self.toggle_button.blockSignals(False)
self.toggle_button.setText(self._get_toggle_text(self.title, expanded))
if persist:
settings = QSettings('MagScope', 'MagScope')
settings.setValue(self._settings_key, self.collapsed)
if animate:
if expanded:
# Expand
self.animation.setStartValue(0)
self.animation.setEndValue(self.content_area.sizeHint().height())
else:
# Collapse
self.animation.setStartValue(self.content_area.height())
self.animation.setEndValue(0)
self.animation.start()
else:
self.animation.stop()
if collapsed:
self.content_area.setMaximumHeight(0)
else:
self.content_area.setMaximumHeight(16777215) # QT default maximum
[docs]
def setContentLayout(self, content_layout):
wrapper_layout = QVBoxLayout()
wrapper_layout.setContentsMargins(5, 0, 5, 5)
#wrapper_layout.setSpacing(4)
# A subtle horizontal line that will have the same width as the content area
sep = QFrame(self.content_area)
sep.setObjectName("groupContentSeparator")
sep.setFrameShape(QFrame.Shape.HLine)
sep.setFrameShadow(QFrame.Shadow.Sunken)
sep.setFixedHeight(1)
wrapper_layout.addWidget(sep)
wrapper_layout.addLayout(content_layout)
self.content_area.setLayout(wrapper_layout)
@staticmethod
[docs]
def _get_toggle_text(title, expanded):
arrow = '▼' if expanded else '❯'
return f' {arrow} {title}'
[docs]
class GripHandle(QSplitterHandle):
""" Simple class for adding '...' to QSplitter handles."""
[docs]
released: pyqtSignal = pyqtSignal()
def __init__(self, orientation, parent):
super().__init__(orientation, parent)
[docs]
def mousePressEvent(self, e):
self._pressed = True
super().mousePressEvent(e)
self.update()
[docs]
def mouseReleaseEvent(self, e):
self._pressed = False
super().mouseReleaseEvent(e)
self.update()
self.released.emit()
[docs]
def enterEvent(self, e):
super().enterEvent(e)
self.update()
[docs]
def leaveEvent(self, e):
super().leaveEvent(e)
self.update()
[docs]
def paintEvent(self, event):
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
# Background with simple states
base = QPalette().mid().color() # QColor("#1e1e1e")
pressed = QPalette().light().color()
hover = QPalette().midlight().color()
dot = QPalette().light().color()
if self._pressed:
color = pressed
elif self.underMouse():
color = hover
else:
color = base
p.setBrush(color)
p.setPen(Qt.PenStyle.NoPen)
p.drawRoundedRect(self.rect(), 6, 6)
# Grip dots centered
if self.orientation() == Qt.Orientation.Horizontal:
cx = self.width() // 2
top = self.height() // 2 - 12
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(dot)
for i in range(5):
p.drawEllipse(QRect(cx - 2, top + i * 6, 4, 4))
else:
cy = self.height() // 2
left = self.width() // 2 - 12
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(dot)
for i in range(5):
p.drawEllipse(QRect(left + i * 6, cy - 2, 4, 4))
[docs]
class GripSplitter(QSplitter):
""" Simple class for adding '...' to QSplitter handles."""
def __init__(self, orientation, name=None, parent=None):
super().__init__(orientation, parent)
self.setChildrenCollapsible(False)
self.setHandleWidth(12)
[docs]
self.shown_once = False
if name:
self.setting_name = name + ' Grip Splitter Sizes'
else:
self.setting_name = None
[docs]
def showEvent(self, e):
super().showEvent(e)
if self.setting_name and not self.shown_once:
self.shown_once = True
settings = QSettings('MagScope', 'MagScope')
sizes = settings.value(self.setting_name, None, list)
if sizes:
sizes = list(map(int, sizes))
self.setSizes(sizes)
[docs]
def createHandle(self):
handle = GripHandle(self.orientation(), self)
handle.released.connect(self.handle_released)
return handle
[docs]
def handle_released(self):
if self.setting_name:
settings = QSettings('MagScope', 'MagScope')
settings.setValue(self.setting_name, self.sizes())
[docs]
class BeadGraphic(QGraphicsRectItem):
[docs]
LABEL_FONT = QFont('Arial', 10)
[docs]
LABEL_COLOR = QColor(255, 255, 255, 255)
[docs]
BORDER_COLOR_DEFAULT = (0, 255, 255, 255)
[docs]
FILL_COLOR_DEFAULT = None
[docs]
BORDER_COLOR_SELECTED = (255, 0, 0, 255)
[docs]
FILL_COLOR_SELECTED = None
[docs]
BORDER_COLOR_REFERENCE = (0, 255, 0, 255)
[docs]
FILL_COLOR_REFERENCE = None
[docs]
HOVER_BORDER_COLOR = (255, 96, 96, 255)
[docs]
DRAG_BORDER_COLOR = (255, 255, 255, 255)
[docs]
_shared_pens: dict[str, QPen] | None = None
[docs]
_shared_brushes: dict[str, QBrush] | None = None
def __init__(
self,
parent: UIManager,
id: int,
roi: tuple[int, int, int, int],
view_scene,
):
[docs]
self._parent: UIManager = parent
[docs]
self._initializing: bool = True
[docs]
self._is_moving: bool = False
[docs]
self._is_hovered: bool = False
[docs]
self._color_state: str = 'default'
[docs]
self._cached_roi: tuple[int, int, int, int] | None = None
self._ensure_shared_pens_and_brushes()
# Set up the graphic (must happen in this order)
super().__init__()
[docs]
self.label = QGraphicsSimpleTextItem(str(id), self)
self.label.setFont(self.LABEL_FONT)
self.label.setBrush(self.LABEL_COLOR)
self.label.setFlag(
QGraphicsItem.GraphicsItemFlag.ItemIgnoresTransformations,
True,
)
self.label.setPos(self.LABEL_OFFSET_X, self.LABEL_OFFSET_Y)
self.setAcceptHoverEvents(True)
self.locked = False # initializes colors
# Add to the scene
[docs]
self.view_scene = view_scene
self.view_scene.addItem(self)
# Set position
self.set_roi_bounds(roi)
# Configure scene
self._update_cached_roi()
self._initializing = False
[docs]
def remove(self):
self.view_scene.removeItem(self)
@classmethod
[docs]
def roi_from_center(
cls,
x: float,
y: float,
width: float,
) -> tuple[int, int, int, int]:
half_width = width / 2
x0 = int(round(x - half_width))
y0 = int(round(y - half_width))
x1 = int(round(x0 + width))
y1 = int(round(y0 + width))
return (x0, x1, y0, y1)
@classmethod
[docs]
def label_scene_position_for_roi(cls, roi: tuple[int, int, int, int]) -> QPointF:
x0, _x1, y0, _y1 = roi
return QPointF(x0 + cls.LABEL_OFFSET_X, y0 + cls.LABEL_OFFSET_Y)
@classmethod
[docs]
def clamp_roi_to_scene(
cls,
roi: tuple[int, int, int, int],
scene_rect: QRectF,
) -> tuple[int, int, int, int]:
if scene_rect.isNull():
return roi
x0, x1, y0, y1 = roi
width = x1 - x0
height = y1 - y0
min_x = int(round(scene_rect.left()))
max_x = int(round(scene_rect.right() - width))
min_y = int(round(scene_rect.top()))
max_y = int(round(scene_rect.bottom() - height))
if max_x < min_x or max_y < min_y:
return roi
x0 = min(max(x0, min_x), max_x)
y0 = min(max(y0, min_y), max_y)
return (x0, x0 + width, y0, y0 + height)
@classmethod
[docs]
def move_roi(
cls,
roi: tuple[int, int, int, int],
dx: int,
dy: int,
scene_rect: QRectF,
) -> tuple[int, int, int, int]:
x0, x1, y0, y1 = roi
moved_roi = (x0 + dx, x1 + dx, y0 + dy, y1 + dy)
return cls.clamp_roi_to_scene(moved_roi, scene_rect)
@property
[docs]
def locked(self):
return self._locked
@locked.setter
def locked(self, locked: bool):
self._locked = locked
# Draggable
self.setFlag(QGraphicsRectItem.GraphicsItemFlag.ItemIsMovable, not locked)
self.setFlag(QGraphicsRectItem.GraphicsItemFlag.ItemSendsScenePositionChanges, not locked)
# Color
self._apply_color()
[docs]
def set_selection_state(self, state: str):
"""Update the bead overlay color to match selection/reference state."""
if state == self._color_state:
return
self._color_state = state
self._apply_color()
@classmethod
[docs]
def _ensure_shared_pens_and_brushes(cls) -> None:
if cls._shared_pens is None:
cls._shared_pens = {
'default': cls._create_pen(cls.BORDER_COLOR_DEFAULT),
'selected': cls._create_pen(cls.BORDER_COLOR_SELECTED),
'reference': cls._create_pen(cls.BORDER_COLOR_REFERENCE),
}
if cls._shared_brushes is None:
cls._shared_brushes = {
'default': cls._create_brush(cls.FILL_COLOR_DEFAULT),
'selected': cls._create_brush(cls.FILL_COLOR_SELECTED),
'reference': cls._create_brush(cls.FILL_COLOR_REFERENCE),
}
@staticmethod
[docs]
def _create_pen(color: tuple[int, int, int, int]) -> QPen:
pen = QPen(QColor(*color))
pen.setWidth(0)
pen.setCosmetic(True)
return pen
@staticmethod
[docs]
def _create_brush(color: tuple[int, int, int, int] | None) -> QBrush:
if color is None:
return QBrush(Qt.BrushStyle.NoBrush)
return QBrush(QColor(*color))
[docs]
def _apply_color(self):
assert self._shared_pens is not None
assert self._shared_brushes is not None
self.setPen(self._shared_pens[self._color_state])
self.setBrush(self._shared_brushes[self._color_state])
self._update_cursor()
self.update()
[docs]
def _current_visual_state(self) -> str:
if self._is_moving and not self.locked:
return 'dragging'
if self._is_hovered and not self.locked:
return 'hover'
return self._color_state
[docs]
def _current_border_color(self) -> QColor:
visual_state = self._current_visual_state()
if visual_state == 'dragging':
return QColor(*self.DRAG_BORDER_COLOR)
if visual_state == 'hover':
return QColor(*self.HOVER_BORDER_COLOR)
if visual_state == 'selected':
return QColor(*self.BORDER_COLOR_SELECTED)
if visual_state == 'reference':
return QColor(*self.BORDER_COLOR_REFERENCE)
return QColor(*self.BORDER_COLOR_DEFAULT)
[docs]
def _current_pen_width(self) -> int:
visual_state = self._current_visual_state()
if visual_state == 'dragging':
return self.DRAG_PEN_WIDTH
if visual_state == 'hover':
return self.HOVER_PEN_WIDTH
if visual_state == 'selected':
return self.SELECTED_PEN_WIDTH
return self.IDLE_PEN_WIDTH
[docs]
def _update_cursor(self) -> None:
if self.locked:
self.unsetCursor()
return
if self._is_moving:
self.setCursor(Qt.CursorShape.ClosedHandCursor)
return
if self._is_hovered:
self.setCursor(Qt.CursorShape.OpenHandCursor)
return
self.unsetCursor()
[docs]
def _corner_grip_rects(self) -> list[QRectF]:
if self._color_state != 'selected':
return []
rect = self._paint_rect()
grip_size = min(
self.CORNER_GRIP_SIZE,
rect.width() / 3,
rect.height() / 3,
)
return [
QRectF(rect.left(), rect.top(), grip_size, grip_size),
QRectF(rect.right() - grip_size, rect.top(), grip_size, grip_size),
QRectF(rect.left(), rect.bottom() - grip_size, grip_size, grip_size),
QRectF(rect.right() - grip_size, rect.bottom() - grip_size, grip_size, grip_size),
]
[docs]
def _paint_rect(self) -> QRectF:
rect = QRectF(self.rect())
pen_inset = self._current_pen_width() / 2
if pen_inset <= 0:
return rect
return rect.adjusted(pen_inset, pen_inset, -pen_inset, -pen_inset)
[docs]
def _current_scene_rect(self) -> QRectF:
scene_rect_getter = getattr(self._parent, '_current_scene_rect', None)
if callable(scene_rect_getter):
scene_rect = scene_rect_getter()
if not scene_rect.isNull():
return scene_rect
return self.scene().sceneRect()
[docs]
def paint(self, painter, option, widget=None):
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
pen = QPen(self._current_border_color())
pen.setWidth(self._current_pen_width())
pen.setCosmetic(True)
painter.setPen(pen)
painter.setBrush(self.brush())
painter.drawRect(self._paint_rect())
grip_rects = self._corner_grip_rects()
if grip_rects:
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(self._current_border_color())
for grip_rect in grip_rects:
painter.drawRect(grip_rect)
painter.restore()
[docs]
def move(self, dx, dy):
value = self.pos()
value.setX(value.x() + dx)
value.setY(value.y() + dy)
value = self.validate_move(value)
self.setPos(value)
self._update_cached_roi()
[docs]
def validate_move(self, value):
""" Prevents the graphic from moving outside the scene border"""
scene_rect = self._current_scene_rect()
roi_rect = self.rect()
roi_width = roi_rect.width()
roi_height = roi_rect.height()
if value.x() < scene_rect.left() - self.pen_width / 2:
value.setX(scene_rect.left() - self.pen_width / 2)
elif value.x() + roi_width > scene_rect.right(
) + self.pen_width / 2:
value.setX(scene_rect.right() + self.pen_width / 2 -
roi_width)
if value.y() < scene_rect.top() - self.pen_width / 2:
value.setY(scene_rect.top() - self.pen_width / 2)
elif value.y() + roi_height > scene_rect.bottom(
) + self.pen_width / 2:
value.setY(scene_rect.bottom() + self.pen_width / 2 -
roi_height)
return value
[docs]
def get_label_scene_position(self) -> QPointF:
return self.label_scene_position_for_roi(self.get_roi_bounds())
[docs]
def set_roi_bounds(self, roi: tuple[int, int, int, int]) -> None:
x0, x1, y0, y1 = roi
width = x1 - x0
height = y1 - y0
offset = self.pen_width / 2
self.setRect(QRectF(offset, offset, width - self.pen_width, height - self.pen_width))
self.setPos(QPointF(x0, y0))
self._update_cached_roi()
[docs]
def itemChange(self, change, value):
# Constrain the item's movement within the scene
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
value = self.validate_move(value)
if (
not self._initializing
and not self._is_moving
and not self._parent.bead_roi_updates_suppressed
):
self.on_move_completed()
elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
self._update_cached_roi()
if self._is_moving:
self.update()
return super().itemChange(change, value)
[docs]
def hoverEnterEvent(self, event):
self._is_hovered = True
self._update_cursor()
self.update()
super().hoverEnterEvent(event)
[docs]
def hoverLeaveEvent(self, event):
self._is_hovered = False
self._update_cursor()
self.update()
super().hoverLeaveEvent(event)
[docs]
def mousePressEvent(self, event):
# Left click - Maybe move
if event.button() == Qt.MouseButton.LeftButton and not self.locked:
self._is_moving = True
self._update_cursor()
self.update()
super().mousePressEvent(event)
# Right click is handled by the scene/view release path so overlapping
# ROIs only delete a single bead.
elif event.button() == Qt.MouseButton.RightButton:
event.ignore()
else:
super().mousePressEvent(event)
[docs]
def mouseReleaseEvent(self, event):
# Call function when done moving
if event.button() == Qt.MouseButton.LeftButton and self._is_moving and not self.locked:
self._is_moving = False
self._update_cursor()
self.update()
self.on_move_completed()
super().mouseReleaseEvent(event)
[docs]
def on_move_completed(self):
self._parent.on_active_bead_move_completed(self.id, self.get_roi_bounds())
[docs]
def get_roi_bounds(self) -> tuple[int, int, int, int]:
if self._cached_roi is None:
self._update_cached_roi()
assert self._cached_roi is not None
return self._cached_roi
[docs]
def _update_cached_roi(self):
rect = self.rect()
x = self.x()
y = self.y()
tl = QPointF(x + rect.left(), y + rect.top())
br = QPointF(x + rect.right(), y + rect.bottom())
x0 = int(round(tl.x() - self.pen_width / 2))
x1 = int(round(br.x() + self.pen_width / 2))
y0 = int(round(tl.y() - self.pen_width / 2))
y1 = int(round(br.y() + self.pen_width / 2))
self._cached_roi = (x0, x1, y0, y1)
[docs]
class FlashLabel(QLabel):
def __init__(self, text=""):
super().__init__(text)
[docs]
self._flash_progress = 0.0
[docs]
self._timer = QTimer(self)
self._timer.timeout.connect(self._update_flash)
# Set initial white text color
self.setStyleSheet("color: white;")
[docs]
def _update_flash(self):
self._step += 1
# Quick flash to red, then fade back to white
if self._step <= 5:
self._flash_progress = self._step / 5.0 # 0 to 1
else:
self._flash_progress = 1.0 - (self._step - 5) / 35.0 # 1 to 0
# Calculate color (white to red interpolation)
red = int(255)
green = int(255 * (1 - self._flash_progress))
blue = int(255 * (1 - self._flash_progress))
self.setStyleSheet(f"color: rgb({red}, {green}, {blue});")
# Stop after 40 steps
if self._step >= 40:
self._timer.stop()
self._step = 0
self.setStyleSheet("color: white;")
[docs]
def setText(self, text):
if text != self.text():
super().setText(text)
# Start flash animation
if self._timer.isActive():
self._timer.stop()
self._step = 0
self._timer.start(15)
else:
super().setText(text)
[docs]
class ResizableLabel(QLabel):
"""Custom QLabel that emits a signal when it's resized."""
[docs]
resized = pyqtSignal(int, int)
def __init__(self, parent=None):
super().__init__(parent)
[docs]
def resizeEvent(self, event):
"""Override resize event to emit signal with new dimensions."""
super().resizeEvent(event)
size = event.size()
self.resized.emit(size.width(), size.height())