from __future__ import annotations from typing import Optional from PyQt6.QtCore import QPoint, QRect, Qt from PyQt6.QtGui import QPainter, QPixmap from PyQt6.QtWidgets import QGraphicsPixmapItem, QGraphicsScene, QGraphicsTextItem, QGraphicsView, QRubberBand, QWidget class InteractiveImageView(QGraphicsView): """Image view with wheel-zoom and drag-to-zoom rectangle.""" def __init__(self, title: str, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self._title = title self._scene = QGraphicsScene(self) self.setScene(self._scene) self._pix_item = QGraphicsPixmapItem() self._scene.addItem(self._pix_item) self._text_item = QGraphicsTextItem(f"{title}: no image") self._scene.addItem(self._text_item) self._drag_start: Optional[QPoint] = None self._pan_last: Optional[QPoint] = None self._rubber = QRubberBand(QRubberBand.Shape.Rectangle, self.viewport()) self.setRenderHints(QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform) self.setDragMode(QGraphicsView.DragMode.NoDrag) self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter) self.setMinimumHeight(360) self.setStyleSheet("border: 1px solid #d0d0d0; background: #fafafa;") def set_placeholder(self, text: str) -> None: self._pix_item.setPixmap(QPixmap()) self._text_item.setPlainText(text) self._text_item.setVisible(True) self.resetTransform() def set_image_bytes(self, content: bytes) -> bool: pix = QPixmap() if not pix.loadFromData(content): self.set_placeholder(f"{self._title}: invalid image") return False return self.set_pixmap(pix) def set_pixmap(self, pix: QPixmap) -> bool: if pix.isNull(): self.set_placeholder(f"{self._title}: invalid image") return False self._pix_item.setPixmap(pix) self._text_item.setVisible(False) self.resetTransform() self.fitInView(self._pix_item, Qt.AspectRatioMode.KeepAspectRatio) return True def wheelEvent(self, event) -> None: # type: ignore[override] delta = event.angleDelta().y() factor = 1.15 if delta > 0 else 1.0 / 1.15 self.scale(factor, factor) def mousePressEvent(self, event) -> None: # type: ignore[override] if self._pix_item.pixmap().isNull(): super().mousePressEvent(event) return if event.button() == Qt.MouseButton.LeftButton: self._drag_start = event.position().toPoint() self._rubber.setGeometry(QRect(self._drag_start, self._drag_start)) self._rubber.show() event.accept() return if event.button() == Qt.MouseButton.RightButton: self._pan_last = event.position().toPoint() self.setCursor(Qt.CursorShape.ClosedHandCursor) event.accept() return super().mousePressEvent(event) def mouseMoveEvent(self, event) -> None: # type: ignore[override] if self._drag_start is not None: cur = event.position().toPoint() self._rubber.setGeometry(QRect(self._drag_start, cur).normalized()) event.accept() return if self._pan_last is not None: cur = event.position().toPoint() delta = cur - self._pan_last self._pan_last = cur self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() - delta.x()) self.verticalScrollBar().setValue(self.verticalScrollBar().value() - delta.y()) event.accept() return super().mouseMoveEvent(event) def mouseReleaseEvent(self, event) -> None: # type: ignore[override] if event.button() == Qt.MouseButton.LeftButton and self._drag_start is not None: rect = self._rubber.geometry() self._rubber.hide() self._drag_start = None if rect.width() > 10 and rect.height() > 10: scene_rect = self.mapToScene(rect).boundingRect() self.fitInView(scene_rect, Qt.AspectRatioMode.KeepAspectRatio) event.accept() return if event.button() == Qt.MouseButton.RightButton and self._pan_last is not None: self._pan_last = None self.setCursor(Qt.CursorShape.ArrowCursor) event.accept() return super().mouseReleaseEvent(event) def mouseDoubleClickEvent(self, event) -> None: # type: ignore[override] if not self._pix_item.pixmap().isNull(): self.resetTransform() self.fitInView(self._pix_item, Qt.AspectRatioMode.KeepAspectRatio) event.accept() return super().mouseDoubleClickEvent(event)