"""Draw rounded crop tiles with shared annotation-screen chrome.
The annotation and montage views use this module so their clipping and
selection states stay visually consistent. A tile composes three layers:
┌── current ring (white) — the tile the next action targets
│ ┌── state ring — resting gray, or the crop's class colour
│ │ ┌── the crop itself, clipped to a rounded rect (a real round
│ │ │ corner, not a rounded frame laid over a square image)
The rings use fixed insets, so hover adds the outer current ring without
moving the crop or replacing its class-colored state ring.
"""
from PySide6.QtCore import QRectF
from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen, QPixmap
#: Width of the thin state border around every crop, in pixels.
BORDER_WIDTH = 2
#: The current-tile ring, drawn OUTSIDE the state ring.
HOVER_RING_WIDTH = 3
#: Chrome per side, in pixels.
TILE_INSET = HOVER_RING_WIDTH + BORDER_WIDTH
#: Outer corner radius of the rounded square.
TILE_RADIUS = 10
#: Corner radius of the image inside the chrome.
IMAGE_RADIUS = max(1, TILE_RADIUS - TILE_INSET)
__all__ = ["BORDER_WIDTH", "HOVER_RING_WIDTH", "TILE_INSET", "TILE_RADIUS",
"IMAGE_RADIUS", "cover_rect", "stroke_ring", "paint_tile"]
[docs]
def cover_rect(pixmap: QPixmap, box: QRectF) -> QRectF:
"""Rect to draw ``pixmap`` into so it fills ``box`` at its own aspect.
Crops rather than letterboxes -- the clip path trims the overflow -- so a
tile is always a complete rounded square with no canvas showing through
at the corners. A crop that already matches the box comes back unchanged.
:param pixmap: the image to draw; only its size is read. An empty pixmap
returns ``box`` unchanged.
:param box: the rectangle to fill.
"""
width = float(pixmap.width())
height = float(pixmap.height())
if width <= 0 or height <= 0:
return box
scale = max(box.width() / width, box.height() / height)
drawn_w = width * scale
drawn_h = height * scale
return QRectF(box.center().x() - drawn_w / 2.0,
box.center().y() - drawn_h / 2.0, drawn_w, drawn_h)
[docs]
def stroke_ring(painter: QPainter, colour: str, width: int, inset: float,
w: float, h: float) -> None:
"""Stroke one rounded rect inset by ``inset`` from the widget edge.
:param painter: the active painter; its pen is replaced.
:param colour: stroke colour, any string :class:`QColor` accepts.
:param width: pen width in pixels.
:param inset: distance of the ring from the widget edge, in pixels; the
corner radius shrinks by the same amount (to at least 1).
:param w: widget width in pixels.
:param h: widget height in pixels. Nothing is drawn when the inset leaves
no room in either dimension.
"""
if w - 2 * inset <= 0 or h - 2 * inset <= 0:
return
pen = QPen(QColor(colour))
pen.setWidth(int(width))
painter.setPen(pen)
radius = max(1.0, TILE_RADIUS - inset)
painter.drawRoundedRect(
QRectF(inset, inset, w - 2 * inset, h - 2 * inset), radius, radius)
[docs]
def paint_tile(painter: QPainter, w: float, h: float, pixmap, *,
border_colour: str, ring_colour: str = "",
current: bool = False) -> None:
"""Draw one tile: the clipped crop, its state ring, and the hover ring.
:param painter: active Qt painter for the tile widget.
:param w: tile width in painter coordinates.
:param h: tile height in painter coordinates.
:param pixmap: the crop, or ``None`` for an empty slot -- which still
gets its rings, because an empty tile the cursor is on has to look
like the tile the cursor is on.
:param border_colour: colour of the persistent state border; an empty
string omits the border.
:param current: whether the cursor (or the keyboard) is on this tile.
"""
painter.setRenderHint(QPainter.Antialiasing, True)
painter.setBrush(painter.brush().__class__())
inner = QRectF(TILE_INSET, TILE_INSET,
max(0.0, w - 2 * TILE_INSET), max(0.0, h - 2 * TILE_INSET))
if (pixmap is not None and not pixmap.isNull()
and inner.width() > 0 and inner.height() > 0):
clip = QPainterPath()
clip.addRoundedRect(inner, IMAGE_RADIUS, IMAGE_RADIUS)
painter.save()
painter.setClipPath(clip)
painter.drawPixmap(cover_rect(pixmap, inner), pixmap,
QRectF(pixmap.rect()))
painter.restore()
if border_colour:
stroke_ring(painter, border_colour, BORDER_WIDTH,
HOVER_RING_WIDTH + BORDER_WIDTH / 2.0, w, h)
if current and ring_colour:
stroke_ring(painter, ring_colour, HOVER_RING_WIDTH,
HOVER_RING_WIDTH / 2.0, w, h)