380 lines
15 KiB
Python
380 lines
15 KiB
Python
import ctypes
|
||
import os
|
||
import sys
|
||
from ctypes import wintypes
|
||
|
||
from PyQt6.QtWidgets import QWidget, QApplication
|
||
from PyQt6.QtCore import Qt, QPoint, pyqtSignal, QPropertyAnimation, QEasingCurve, QRectF
|
||
from PyQt6.QtGui import (QPainter, QPixmap, QBrush, QColor,
|
||
QPainterPath, QRadialGradient, QLinearGradient,
|
||
QIcon, QRegion)
|
||
|
||
# 球体内容直径;外侧留一圈给投影
|
||
BALL_DIAM = 96
|
||
SHADOW_PAD = 5
|
||
# 窗口边长 = 圆直径(含投影环),main 里 show_near 用此尺寸
|
||
BALL_SIZE = BALL_DIAM + 1 * SHADOW_PAD
|
||
DRAG_THRESHOLD = 6
|
||
|
||
|
||
def _resource_path(*parts: str) -> str:
|
||
"""开发与 PyInstaller 打包后均能定位项目根目录资源。"""
|
||
base = sys._MEIPASS if hasattr(sys, "_MEIPASS") else os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
return os.path.join(base, *parts)
|
||
|
||
|
||
def _resolve_logo_path():
|
||
for name in ("logo.png", "logo.ico"):
|
||
p = _resource_path(name)
|
||
if os.path.isfile(p):
|
||
return p
|
||
return None
|
||
|
||
|
||
def _load_logo_pixmap(path):
|
||
"""ICO 多尺寸时用 QIcon 取较大图,避免位图过小、放大后发糊或带边线感。"""
|
||
if not path:
|
||
return QPixmap()
|
||
if os.path.splitext(path)[1].lower() == ".ico":
|
||
pm = QIcon(path).pixmap(512, 512)
|
||
if not pm.isNull():
|
||
return pm
|
||
return QPixmap(path)
|
||
|
||
|
||
def _win32_set_ellipse_window_rgn(hwnd, w, h):
|
||
"""用系统区域裁切 HWND,w/h 为逻辑像素,内部换算为物理像素。"""
|
||
if not hwnd or w < 2 or h < 2:
|
||
return
|
||
gdi32 = ctypes.windll.gdi32
|
||
user32 = ctypes.windll.user32
|
||
# 获取窗口所在显示器的 DPI,换算为物理像素
|
||
try:
|
||
MONITOR_DEFAULTTONEAREST = 2
|
||
hmon = ctypes.windll.user32.MonitorFromWindow(wintypes.HWND(hwnd), MONITOR_DEFAULTTONEAREST)
|
||
dpi_x = ctypes.c_uint(0)
|
||
dpi_y = ctypes.c_uint(0)
|
||
MDT_EFFECTIVE_DPI = 0
|
||
ctypes.windll.shcore.GetDpiForMonitor(hmon, MDT_EFFECTIVE_DPI, ctypes.byref(dpi_x), ctypes.byref(dpi_y))
|
||
scale = dpi_x.value / 96.0
|
||
except Exception:
|
||
scale = 1.0
|
||
pw = max(2, int(w * scale))
|
||
ph = max(2, int(h * scale))
|
||
hrgn = gdi32.CreateEllipticRgn(0, 0, pw, ph)
|
||
if not hrgn:
|
||
return
|
||
user32.SetWindowRgn(hwnd, hrgn, True)
|
||
|
||
|
||
def _win32_disable_dwm_rounded_shell(hwnd):
|
||
"""减弱 Win11 给无边框窗套的圆角矩形描边/阴影(与自绘圆叠在一起会像方框)。"""
|
||
if not hwnd:
|
||
return
|
||
try:
|
||
dwm = ctypes.windll.dwmapi
|
||
DWMWA_WINDOW_CORNER_PREFERENCE = 33
|
||
DWMWCP_DONOTROUND = 1
|
||
pref = ctypes.c_uint(DWMWCP_DONOTROUND)
|
||
dwm.DwmSetWindowAttribute(
|
||
wintypes.HWND(hwnd),
|
||
wintypes.DWORD(DWMWA_WINDOW_CORNER_PREFERENCE),
|
||
ctypes.byref(pref),
|
||
ctypes.sizeof(pref),
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
class FloatBall(QWidget):
|
||
clicked = pyqtSignal()
|
||
right_clicked = pyqtSignal(QPoint)
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self._stays_on_top = False
|
||
self._apply_window_flags()
|
||
self._logo_path = _resolve_logo_path()
|
||
if self._logo_path:
|
||
self.setWindowIcon(QIcon(self._logo_path))
|
||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
|
||
self.setAutoFillBackground(False)
|
||
# 正方形窗口边长 = 圆直径,绘制也用满圆,避免出现「大圆环套小图」
|
||
self.setFixedSize(BALL_SIZE, BALL_SIZE)
|
||
self._apply_circular_window_shape()
|
||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
|
||
self._logo = _load_logo_pixmap(self._logo_path) if self._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._place_default()
|
||
|
||
def set_stays_on_top(self, on_top):
|
||
"""与主面板图钉联动:仅图钉开启时置顶,避免挡在其他程序前面。"""
|
||
if on_top == self._stays_on_top:
|
||
return
|
||
self._stays_on_top = bool(on_top)
|
||
was_visible = self.isVisible()
|
||
geo = self.geometry()
|
||
self._apply_window_flags()
|
||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground, True)
|
||
if self._logo_path:
|
||
self.setWindowIcon(QIcon(self._logo_path))
|
||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self._apply_circular_window_shape()
|
||
if was_visible:
|
||
self.setGeometry(geo)
|
||
self.show()
|
||
|
||
def _apply_circular_window_shape(self):
|
||
w, h = self.width(), self.height()
|
||
self.setMask(QRegion(0, 0, w, h, QRegion.RegionType.Ellipse))
|
||
if sys.platform == "win32":
|
||
wid = int(self.winId())
|
||
if wid:
|
||
_win32_set_ellipse_window_rgn(wid, w, h)
|
||
_win32_disable_dwm_rounded_shell(wid)
|
||
|
||
def showEvent(self, event):
|
||
super().showEvent(event)
|
||
if sys.platform == "win32":
|
||
wid = int(self.winId())
|
||
if wid:
|
||
_win32_set_ellipse_window_rgn(wid, self.width(), self.height())
|
||
_win32_disable_dwm_rounded_shell(wid)
|
||
|
||
def moveEvent(self, event):
|
||
super().moveEvent(event)
|
||
# 跨屏移动后 DPI 可能变化,重新设置物理像素 region
|
||
if sys.platform == "win32":
|
||
wid = int(self.winId())
|
||
if wid:
|
||
_win32_set_ellipse_window_rgn(wid, self.width(), self.height())
|
||
|
||
def _apply_window_flags(self):
|
||
flags = (
|
||
Qt.WindowType.FramelessWindowHint
|
||
| Qt.WindowType.Tool
|
||
| Qt.WindowType.NoDropShadowWindowHint
|
||
| Qt.WindowType.WindowStaysOnTopHint
|
||
)
|
||
self.setWindowFlags(flags)
|
||
|
||
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 _current_screen_geometry(self):
|
||
"""返回当前窗口所在屏幕的可用区域,找不到时回退到主屏幕。"""
|
||
center = self.geometry().center()
|
||
screen = QApplication.screenAt(center)
|
||
if screen is None:
|
||
screen = QApplication.primaryScreen()
|
||
return screen.availableGeometry()
|
||
|
||
def _place_default(self):
|
||
screen = QApplication.primaryScreen().availableGeometry()
|
||
self.move(
|
||
screen.right() - self.width() - 20,
|
||
screen.top() + screen.height() // 2 - self.height() // 2,
|
||
)
|
||
|
||
def place_on_screen_of(self, widget):
|
||
"""将悬浮球放到指定 widget 所在屏幕的右侧中央。"""
|
||
# frameGeometry().center() 直接是全局坐标,对顶层窗口最可靠
|
||
global_center = widget.frameGeometry().center()
|
||
screen_obj = QApplication.screenAt(global_center)
|
||
if screen_obj is None:
|
||
screen_obj = QApplication.primaryScreen()
|
||
screen = screen_obj.availableGeometry()
|
||
self.move(
|
||
screen.right() - self.width() - 20,
|
||
screen.top() + screen.height() // 2 - self.height() // 2,
|
||
)
|
||
|
||
def _paint_radius_outer(self):
|
||
w, h = self.width(), self.height()
|
||
return min(w, h) * 0.5 - 0.5
|
||
|
||
def _paint_radius_ball(self):
|
||
return self._paint_radius_outer() - SHADOW_PAD
|
||
|
||
def paintEvent(self, event):
|
||
p = QPainter(self)
|
||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
|
||
w, h = self.width(), self.height()
|
||
cx = w * 0.5
|
||
cy = h * 0.5
|
||
r_out = self._paint_radius_outer()
|
||
r = self._paint_radius_ball()
|
||
g = self._glow
|
||
glow_strength = 0.28 + 0.72 * g
|
||
|
||
disc_out = QRectF(cx - r_out, cy - r_out, r_out * 2, r_out * 2)
|
||
disc = QRectF(cx - r, cy - r, r * 2, r * 2)
|
||
|
||
# 0. 外环投影(内容圆与窗口边之间的环形:下重上轻,模拟落地阴影)
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
outer_path = QPainterPath()
|
||
outer_path.addEllipse(disc_out)
|
||
inner_hole = QPainterPath()
|
||
inner_hole.addEllipse(disc)
|
||
ring = outer_path.subtracted(inner_hole)
|
||
sh = QLinearGradient(0, cy - r_out, 0, cy + r_out)
|
||
sh.setColorAt(0.0, QColor(0, 0, 0, 0))
|
||
sh.setColorAt(0.45, QColor(0, 0, 0, 0))
|
||
sh.setColorAt(0.62, QColor(0, 0, 0, int(18 + 22 * glow_strength)))
|
||
sh.setColorAt(0.82, QColor(0, 0, 0, int(38 + 35 * g)))
|
||
sh.setColorAt(1.0, QColor(0, 0, 0, int(52 + 40 * g)))
|
||
p.fillPath(ring, QBrush(sh))
|
||
|
||
clip = QPainterPath()
|
||
clip.addEllipse(disc)
|
||
p.setClipPath(clip)
|
||
|
||
# 1. 毛玻璃底:半透明冷白渐变(磨砂基底)
|
||
frost_bg = QLinearGradient(0, cy - r, 0, cy + r)
|
||
frost_bg.setColorAt(0.0, QColor(255, 255, 255, int(175 + 35 * glow_strength)))
|
||
frost_bg.setColorAt(0.42, QColor(244, 249, 255, int(130 + 40 * glow_strength)))
|
||
frost_bg.setColorAt(1.0, QColor(228, 236, 248, int(155 + 45 * glow_strength)))
|
||
p.setBrush(QBrush(frost_bg))
|
||
p.drawEllipse(disc)
|
||
|
||
# 2. 斜向磨砂高光(模拟玻璃反光)
|
||
sheen = QLinearGradient(cx - r, cy - r, cx + r * 0.55, cy + r * 0.85)
|
||
sheen.setColorAt(0.0, QColor(255, 255, 255, 0))
|
||
sheen.setColorAt(0.38, QColor(255, 255, 255, int(38 + 45 * glow_strength)))
|
||
sheen.setColorAt(0.52, QColor(255, 255, 255, int(14 + 18 * glow_strength)))
|
||
sheen.setColorAt(1.0, QColor(255, 255, 255, 0))
|
||
p.setCompositionMode(QPainter.CompositionMode.CompositionMode_SoftLight)
|
||
p.setBrush(QBrush(sheen))
|
||
p.drawEllipse(disc)
|
||
p.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
|
||
|
||
# 3. Logo(cover 铺满圆)
|
||
if not self._logo.isNull():
|
||
p.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True)
|
||
side = max(1, int(2 * r))
|
||
scaled = self._logo.scaled(
|
||
side, side,
|
||
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
|
||
Qt.TransformationMode.SmoothTransformation
|
||
)
|
||
# 居中裁切到 side×side,避免 Expanding 后图片偏大导致偏移
|
||
ox = (scaled.width() - side) // 2
|
||
oy = (scaled.height() - side) // 2
|
||
cropped = scaled.copy(ox, oy, side, side)
|
||
p.drawPixmap(int(cx - side / 2), int(cy - side / 2), cropped)
|
||
|
||
# 4. 覆在图上的雾面(毛玻璃「蒙在内容上」)
|
||
veil = QLinearGradient(cx, cy - r, cx, cy + r)
|
||
veil.setColorAt(0.0, QColor(255, 255, 255, int(52 + 38 * g)))
|
||
veil.setColorAt(0.35, QColor(255, 255, 255, int(28 + 22 * g)))
|
||
veil.setColorAt(0.75, QColor(248, 252, 255, int(18 + 15 * g)))
|
||
veil.setColorAt(1.0, QColor(240, 245, 252, int(22 + 18 * g)))
|
||
p.setBrush(QBrush(veil))
|
||
p.drawEllipse(disc)
|
||
|
||
# 5. 内缘光晕(边缘略亮,悬停时更柔)
|
||
rim = QRadialGradient(cx, cy, r)
|
||
rim.setColorAt(0.0, QColor(255, 255, 255, 0))
|
||
rim.setColorAt(0.62, QColor(255, 255, 255, 0))
|
||
rim.setColorAt(0.88, QColor(255, 255, 255, int(28 + 55 * glow_strength)))
|
||
rim.setColorAt(0.97, QColor(255, 255, 255, int(45 + 85 * glow_strength)))
|
||
rim.setColorAt(1.0, QColor(255, 255, 255, int(22 + 40 * glow_strength)))
|
||
p.setBrush(QBrush(rim))
|
||
p.drawEllipse(disc)
|
||
|
||
# 6. 顶区高光(玻璃上沿反光)
|
||
hi = QRadialGradient(cx, cy - r * 0.22, r * 1.02)
|
||
hi.setColorAt(0.0, QColor(255, 255, 255, int(42 + 55 * glow_strength)))
|
||
hi.setColorAt(0.45, QColor(255, 255, 255, int(12 + 18 * glow_strength)))
|
||
hi.setColorAt(1.0, QColor(255, 255, 255, 0))
|
||
p.setBrush(QBrush(hi))
|
||
p.drawEllipse(disc)
|
||
|
||
p.setClipping(False)
|
||
p.end()
|
||
|
||
def mousePressEvent(self, event):
|
||
if event.button() == Qt.MouseButton.LeftButton:
|
||
self._drag_start = event.globalPosition().toPoint()
|
||
self._dragging = False
|
||
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()
|
||
# 用鼠标当前位置所在屏幕来限制边界,支持多显示器拖动
|
||
cursor_screen = QApplication.screenAt(event.globalPosition().toPoint())
|
||
if cursor_screen is None:
|
||
cursor_screen = QApplication.primaryScreen()
|
||
screen = cursor_screen.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()
|
||
|
||
def enterEvent(self, event):
|
||
self._hovered = True
|
||
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()
|
||
|
||
def _snap_to_edge(self):
|
||
screen = self._current_screen_geometry()
|
||
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()
|