Files
lintunes/lintunes/gui/drag_ghost.py
T
trav b7f8761bd1 Import existing LinTunes project
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.
2026-06-26 21:12:01 -04:00

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