Snapshot of the existing codebase before working through the TASKS.md backlog. Real library data (data/) and the iTunes import fixture (itunes-test-library/) are gitignored.
138 lines
4.7 KiB
Python
138 lines
4.7 KiB
Python
"""A cursor-following drag indicator that works on Wayland.
|
|
|
|
GNOME/Mutter does not render the translucent drag-icon surface produced by
|
|
``QDrag.setPixmap()``, and the other native hooks (``setDragCursor``,
|
|
``setOverrideCursor``) are unreliable during a drag too. So instead of asking
|
|
the compositor to draw the drag image, we paint our own frameless overlay as a
|
|
child of the top-level window and move it ourselves from the drag-move events
|
|
Qt delivers to our drop targets (``QCursor.pos()`` is also unreliable on
|
|
Wayland, so we never poll it).
|
|
|
|
The little song icon shown under the cursor is loaded from
|
|
``packaging/drag-icon.png`` — the same folder as the application icon — so it
|
|
can be swapped out and customized. If that file is missing we fall back to a
|
|
hand-drawn page-and-note glyph so the feedback never silently disappears.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
from PyQt6.QtCore import Qt, QPoint, QPointF, QRectF
|
|
from PyQt6.QtGui import QPixmap, QPainter, QColor, QPen, QPolygonF
|
|
from PyQt6.QtWidgets import QWidget
|
|
|
|
# Sits next to packaging/lintunes.png (the app-icon source). parents[2] is the
|
|
# repo root: lintunes/gui/drag_ghost.py -> gui -> lintunes -> <root>.
|
|
ICON_PATH = Path(__file__).resolve().parents[2] / "packaging" / "drag-icon.png"
|
|
|
|
_base_cache: QPixmap | None = None
|
|
|
|
|
|
def _base_pixmap() -> QPixmap:
|
|
"""The full-resolution source icon (customizable PNG or drawn fallback)."""
|
|
global _base_cache
|
|
if _base_cache is None:
|
|
pixmap = QPixmap(str(ICON_PATH))
|
|
if pixmap.isNull():
|
|
pixmap = _draw_fallback(256)
|
|
_base_cache = pixmap
|
|
return _base_cache
|
|
|
|
|
|
def song_icon(size: int, ratio: float = 1.0) -> QPixmap:
|
|
"""The drag icon scaled to ``size`` logical points for the given DPR."""
|
|
px = max(1, round(size * ratio))
|
|
scaled = _base_pixmap().scaled(
|
|
px, px, Qt.AspectRatioMode.KeepAspectRatio,
|
|
Qt.TransformationMode.SmoothTransformation)
|
|
scaled.setDevicePixelRatio(ratio)
|
|
return scaled
|
|
|
|
|
|
def _draw_fallback(size: int) -> QPixmap:
|
|
"""A simple page-with-folded-corner and a music note, app-icon style."""
|
|
pixmap = QPixmap(size, size)
|
|
pixmap.fill(Qt.GlobalColor.transparent)
|
|
p = QPainter(pixmap)
|
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
s = size
|
|
# Page body with a folded top-right corner.
|
|
fold = s * 0.26
|
|
body = QPolygonF([
|
|
QPointF(s * 0.18, s * 0.10), QPointF(s * 0.66, s * 0.10),
|
|
QPointF(s * 0.82, s * 0.10 + fold), QPointF(s * 0.82, s * 0.90),
|
|
QPointF(s * 0.18, s * 0.90),
|
|
])
|
|
p.setPen(Qt.PenStyle.NoPen)
|
|
p.setBrush(QColor("#f4f4f5"))
|
|
p.drawPolygon(body)
|
|
p.setBrush(QColor("#c8c8cc"))
|
|
p.drawPolygon(QPolygonF([
|
|
QPointF(s * 0.66, s * 0.10), QPointF(s * 0.66, s * 0.10 + fold),
|
|
QPointF(s * 0.82, s * 0.10 + fold),
|
|
]))
|
|
# Music note: a filled head plus a stem.
|
|
note = QColor("#2b6cb0")
|
|
p.setBrush(note)
|
|
p.drawEllipse(QPointF(s * 0.40, s * 0.70), s * 0.085, s * 0.065)
|
|
pen = QPen(note, max(1.0, s * 0.045))
|
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
|
p.setPen(pen)
|
|
p.drawLine(QPointF(s * 0.485, s * 0.685), QPointF(s * 0.485, s * 0.40))
|
|
p.drawLine(QPointF(s * 0.485, s * 0.40), QPointF(s * 0.62, s * 0.46))
|
|
p.end()
|
|
return pixmap
|
|
|
|
|
|
class _Ghost(QWidget):
|
|
"""Frameless, click-through overlay that just paints a pixmap."""
|
|
|
|
def __init__(self, parent):
|
|
super().__init__(parent)
|
|
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
|
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
|
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
|
|
self._pixmap = QPixmap()
|
|
self._hotspot = QPoint(0, 0)
|
|
|
|
def set_content(self, pixmap: QPixmap, hotspot: QPoint):
|
|
self._pixmap = pixmap
|
|
self._hotspot = hotspot
|
|
self.setFixedSize(pixmap.deviceIndependentSize().toSize())
|
|
self.update()
|
|
|
|
def paintEvent(self, _event):
|
|
painter = QPainter(self)
|
|
painter.drawPixmap(0, 0, self._pixmap)
|
|
|
|
|
|
_ghost: _Ghost | None = None
|
|
|
|
|
|
def begin(window, pixmap: QPixmap, hotspot: QPoint, global_pos: QPoint):
|
|
"""Show the drag overlay on ``window`` (a top-level widget)."""
|
|
global _ghost
|
|
end()
|
|
_ghost = _Ghost(window)
|
|
_ghost.set_content(pixmap, hotspot)
|
|
move(global_pos)
|
|
_ghost.show()
|
|
_ghost.raise_()
|
|
|
|
|
|
def move(global_pos: QPoint):
|
|
"""Reposition so the hotspot sits under the cursor. No-op when inactive."""
|
|
if _ghost is None:
|
|
return
|
|
local = _ghost.parentWidget().mapFromGlobal(global_pos)
|
|
_ghost.move(local - _ghost._hotspot)
|
|
_ghost.raise_()
|
|
|
|
|
|
def end():
|
|
"""Tear down the overlay when the drag finishes."""
|
|
global _ghost
|
|
if _ghost is not None:
|
|
_ghost.hide()
|
|
_ghost.deleteLater()
|
|
_ghost = None
|