Source code for magscope.ui.widgets

""""
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
[docs] self.left_button = QPushButton(left_button_text)
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
[docs] self.right_button = QPushButton(right_button_text)
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.title = title
[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)
[docs] self.toggle_button = QPushButton( self._get_toggle_text(title, not collapsed))
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] self._pressed = False
[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.name = name
[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] LABEL_OFFSET_X = 10
[docs] LABEL_OFFSET_Y = 1
[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] IDLE_PEN_WIDTH = 0
[docs] HOVER_PEN_WIDTH = 2
[docs] SELECTED_PEN_WIDTH = 2
[docs] DRAG_PEN_WIDTH = 3
[docs] CORNER_GRIP_SIZE = 6.0
[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.id: int = id
[docs] self._initializing: bool = True
[docs] self._is_moving: bool = False
[docs] self._is_hovered: bool = False
[docs] self._locked: bool
[docs] self._color_state: str = 'default'
[docs] self._cached_roi: tuple[int, int, int, int] | None = None
[docs] self.pen_width = 0
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)
[docs] self._step = 0
# 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())