niumasoftware/ui/ball.py
2026-04-03 21:50:36 +08:00

207 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
from PyQt6.QtWidgets import QWidget, QApplication
from PyQt6.QtCore import Qt, QPoint, QTimer, pyqtSignal, QPropertyAnimation, QEasingCurve, QRectF
from PyQt6.QtGui import (QPainter, QPixmap, QBrush, QColor, QPen,
QPainterPath, QRadialGradient, QIcon)
BALL_SIZE = 60
LOGO_SIZE = 34
LOGO_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logo.png")
DRAG_THRESHOLD = 6
class FloatBall(QWidget):
clicked = pyqtSignal()
right_clicked = pyqtSignal(QPoint)
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowFlags(
Qt.WindowType.FramelessWindowHint |
Qt.WindowType.WindowStaysOnTopHint |
Qt.WindowType.Tool
)
if os.path.exists(LOGO_PATH):
self.setWindowIcon(QIcon(LOGO_PATH))
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
# 关键:让整个窗口区域都透明,不显示系统边框
self.setFixedSize(BALL_SIZE + 20, BALL_SIZE + 20) # 留出光晕空间
self.setCursor(Qt.CursorShape.PointingHandCursor)
self._logo = QPixmap(LOGO_PATH) if os.path.exists(LOGO_PATH) else QPixmap()
self._drag_start = QPoint()
self._dragging = False
self._hovered = False
self._glow = 0.0 # 0.0~1.0 光晕强度
# 光晕动画
self._glow_anim = QPropertyAnimation(self, b"_glow_prop")
self._glow_anim.setDuration(300)
self._glow_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
# 闲置淡出
self._idle_timer = QTimer(self)
self._idle_timer.setSingleShot(True)
self._idle_timer.setInterval(3000)
self._idle_timer.timeout.connect(self._fade_out)
self._place_default()
self._idle_timer.start()
# Qt property 用于动画
def _get_glow(self): return self._glow
def _set_glow(self, v): self._glow = v; self.update()
from PyQt6.QtCore import pyqtProperty
_glow_prop = pyqtProperty(float, _get_glow, _set_glow)
def _place_default(self):
screen = QApplication.primaryScreen().availableGeometry()
pad = (self.width() - BALL_SIZE) // 2
self.move(screen.right() - self.width() - 20 + pad,
screen.top() + screen.height() // 2 - self.height() // 2)
# ── 绘制 ────────────────────────────────────────────
def paintEvent(self, event):
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
pad = (self.width() - BALL_SIZE) // 2 # 光晕留白
cx = self.width() / 2
cy = self.height() / 2
r = BALL_SIZE / 2 - 1
# 1. 外部光晕hover 时)
if self._glow > 0:
glow_r = r + 10 * self._glow
grad = QRadialGradient(cx, cy, glow_r)
grad.setColorAt(0, QColor(255, 255, 255, int(80 * self._glow)))
grad.setColorAt(0.5, QColor(255, 255, 255, int(30 * self._glow)))
grad.setColorAt(1, QColor(255, 255, 255, 0))
p.setBrush(QBrush(grad))
p.setPen(Qt.PenStyle.NoPen)
p.drawEllipse(QRectF(cx - glow_r, cy - glow_r, glow_r * 2, glow_r * 2))
# 2. 圆形裁剪
clip = QPainterPath()
clip.addEllipse(QRectF(cx - r, cy - r, r * 2, r * 2))
p.setClipPath(clip)
# 3. 白色半透明毛玻璃底
p.setBrush(QBrush(QColor(255, 255, 255, 210)))
p.setPen(Qt.PenStyle.NoPen)
p.drawEllipse(QRectF(cx - r, cy - r, r * 2, r * 2))
# 4. 顶部高光
hi = QRadialGradient(cx, cy - r * 0.3, r * 0.9)
hi.setColorAt(0, QColor(255, 255, 255, 140))
hi.setColorAt(1, QColor(255, 255, 255, 0))
p.setBrush(QBrush(hi))
p.drawEllipse(QRectF(cx - r, cy - r, r * 2, r * 2))
# 5. logo 居中
if not self._logo.isNull():
scaled = self._logo.scaled(
LOGO_SIZE, LOGO_SIZE,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
lx = int(cx - scaled.width() / 2)
ly = int(cy - scaled.height() / 2)
p.drawPixmap(lx, ly, scaled)
# 6. 边框(取消裁剪后画)
p.setClipping(False)
p.setBrush(Qt.BrushStyle.NoBrush)
p.setPen(QPen(QColor(200, 200, 200, 120), 1.5))
p.drawEllipse(QRectF(cx - r, cy - r, r * 2, r * 2))
# 7. hover 时内圈白色光晕边
if self._glow > 0:
p.setPen(QPen(QColor(255, 255, 255, int(160 * self._glow)), 2))
p.drawEllipse(QRectF(cx - r + 1, cy - r + 1, (r - 1) * 2, (r - 1) * 2))
p.end()
# ── 鼠标事件 ────────────────────────────────────────
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._drag_start = event.globalPosition().toPoint()
self._dragging = False
self.setWindowOpacity(1.0)
self._idle_timer.stop()
elif event.button() == Qt.MouseButton.RightButton:
self.right_clicked.emit(event.globalPosition().toPoint())
def mouseMoveEvent(self, event):
if not (event.buttons() & Qt.MouseButton.LeftButton):
return
delta = event.globalPosition().toPoint() - self._drag_start
if not self._dragging and delta.manhattanLength() > DRAG_THRESHOLD:
self._dragging = True
if self._dragging:
new_pos = self.pos() + delta
self._drag_start = event.globalPosition().toPoint()
screen = QApplication.primaryScreen().availableGeometry()
new_pos.setX(max(screen.left(), min(new_pos.x(), screen.right() - self.width())))
new_pos.setY(max(screen.top(), min(new_pos.y(), screen.bottom() - self.height())))
self.move(new_pos)
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
if not self._dragging:
self.clicked.emit()
else:
self._snap_to_edge()
self._idle_timer.start()
def enterEvent(self, event):
self._hovered = True
self.setWindowOpacity(1.0)
self._idle_timer.stop()
self._glow_anim.stop()
self._glow_anim.setStartValue(self._glow)
self._glow_anim.setEndValue(1.0)
self._glow_anim.start()
def leaveEvent(self, event):
self._hovered = False
self._glow_anim.stop()
self._glow_anim.setStartValue(self._glow)
self._glow_anim.setEndValue(0.0)
self._glow_anim.start()
self._idle_timer.start()
# ── 吸附边缘 ─────────────────────────────────────────
def _snap_to_edge(self):
screen = QApplication.primaryScreen().availableGeometry()
cx = self.x() + self.width() // 2
cy = self.y() + self.height() // 2
dists = {
"l": cx - screen.left(),
"r": screen.right() - cx,
"t": cy - screen.top(),
"b": screen.bottom() - cy,
}
side = min(dists, key=dists.get)
ends = {
"l": QPoint(screen.left(), self.y()),
"r": QPoint(screen.right() - self.width(), self.y()),
"t": QPoint(self.x(), screen.top()),
"b": QPoint(self.x(), screen.bottom() - self.height()),
}
anim = QPropertyAnimation(self, b"pos")
anim.setDuration(200)
anim.setEasingCurve(QEasingCurve.Type.OutCubic)
anim.setStartValue(self.pos())
anim.setEndValue(ends[side])
self._snap_anim = anim
anim.start()
def _fade_out(self):
self._anim_fade = QPropertyAnimation(self, b"windowOpacity")
self._anim_fade.setDuration(600)
self._anim_fade.setStartValue(1.0)
self._anim_fade.setEndValue(0.3)
self._anim_fade.start()