Files
niumasoftware/ui/dock.py
T
2026-09-23 16:51:40 +08:00

1956 lines
73 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 sys
import os
import json
import threading
import socket
import struct
import time as _time
import datetime
import urllib.request
import webbrowser
import qtawesome as qta
from PyQt6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QScrollArea,
QPushButton,
QApplication,
QMenu,
QSystemTrayIcon,
QLineEdit,
QLabel,
QMessageBox,
QFrame,
)
from PyQt6.QtCore import (
Qt,
QPoint,
QPropertyAnimation,
QEasingCurve,
QTimer,
QEvent,
QSize,
pyqtSignal,
)
from PyQt6.QtGui import QPixmap, QPainter, QColor, QBrush, QPen, QIcon, QCursor
from db import database
from ui.group import GroupWidget
import ui.theme as theme
import ui.dialog_style as dialog_style
from ui.updater import Updater
from app_info import app_title
class _ThemedTooltip(QFrame):
"""跟随主题颜色的自定义 tooltip,替代系统黑色气泡。"""
def __init__(self, text: str):
super().__init__(None, Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
self._lbl = QLabel(text, self)
self._lbl.setObjectName("tip_label")
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(self._lbl)
def refresh_style(self):
t = theme.current()
is_dark = theme.name() == "dark"
bg = "#2d2d2d" if is_dark else "#ffffff"
fg = "#eeeeee" if is_dark else "#111111"
bd = t.get("search_border", "#888")
self._lbl.setStyleSheet(
f"QLabel#tip_label {{"
f"background:{bg}; color:{fg}; border:1px solid {bd};"
f"border-radius:6px; padding:5px 9px; font-size:12px;}}"
)
def _install_themed_tooltip(btn: QPushButton, text: str):
"""给按钮安装自定义 tooltip,完全替换系统气泡。"""
btn.setToolTip("")
tip = _ThemedTooltip(text)
timer = QTimer()
timer.setSingleShot(True)
def _show():
tip.refresh_style()
tip.adjustSize()
pos = QCursor.pos()
tip.move(pos.x() + 14, pos.y() + 22)
tip.show()
tip.raise_()
def _enter(_e):
timer.start(400)
def _leave(_e):
timer.stop()
tip.hide()
timer.timeout.connect(_show)
btn.enterEvent = _enter
btn.leaveEvent = _leave
btn._tip = tip # 防 GC
btn._ttimer = timer # 防 GC
PANEL_W = 260
PANEL_H = 40
MIN_W, MIN_H = 400, 5
ANIM_MS = 180
RESIZE_M = 8
class WeatherBox(QWidget):
"""顶部天气信息模块(重写:大图标 + 左侧文案 + 右侧温度布局)"""
def __init__(self, parent=None):
super().__init__(parent)
self._weather_dir = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "assets", "imgs", "weather"
)
# 顶部栏高度由 PanelWindow 强制对齐(weather_bar = 80)
self.setFixedHeight(80)
# 控制宽度,避免把右侧“网络时间”挤出
self.setFixedWidth(170)
# 左侧:图标(大)+ 天气名称(小一点)
self._left_widget = QWidget()
self._left_widget.setFixedWidth(68)
left_lay = QVBoxLayout(self._left_widget)
left_lay.setContentsMargins(0, 0, 0, 0)
left_lay.setSpacing(6)
self._icon_label = QLabel()
self._icon_label.setFixedSize(70, 70)
self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._icon_label.setStyleSheet("background:transparent;")
self._weather_name_label = QLabel("")
self._weather_name_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._weather_name_label.setStyleSheet(
"font-size:12px; font-weight:700; background:transparent;"
)
self._weather_name_label.setFixedWidth(self._left_widget.width())
left_lay.addWidget(self._icon_label)
left_lay.addWidget(self._weather_name_label)
# 右侧:温度(大)+ 地区 + 风力
self._right_widget = QWidget()
self._right_widget.setFixedWidth(102)
right_lay = QVBoxLayout(self._right_widget)
right_lay.setContentsMargins(0, 0, 0, 0)
right_lay.setSpacing(4)
self._temp_label = QLabel("--°C")
self._temp_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
self._temp_label.setFixedWidth(self._right_widget.width())
self._temp_label.setStyleSheet(
"font-weight:900; font-size:28px; background:transparent; padding:0px; margin:0px;"
)
self._location_label = QLabel("")
self._location_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
self._location_label.setFixedWidth(self._right_widget.width())
self._location_label.setStyleSheet(
"font-size:12px; font-weight:700; background:transparent;"
)
self._wind_label = QLabel("")
self._wind_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
self._wind_label.setFixedWidth(self._right_widget.width())
self._wind_label.setStyleSheet(
"font-size:12px; background:transparent;"
)
right_lay.addWidget(self._temp_label)
right_lay.addWidget(self._location_label)
right_lay.addWidget(self._wind_label)
lay = QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(6)
lay.addWidget(self._left_widget)
lay.addWidget(self._right_widget)
def set_theme_colors(self, color_fg: str):
self._temp_label.setStyleSheet(
f"font-weight:900; font-size:28px; color:{color_fg}; background:transparent; padding:0px; margin:0px;"
)
self._weather_name_label.setStyleSheet(
f"font-size:12px; font-weight:700; color:{color_fg}; background:transparent;"
)
self._location_label.setStyleSheet(
f"font-size:12px; font-weight:700; color:{color_fg}; background:transparent;"
)
self._wind_label.setStyleSheet(
f"font-size:12px; color:{color_fg}; background:transparent;"
)
def _set_elided(self, label: QLabel, text: str):
text = text or ""
# 统一右侧省略,避免撑爆布局
fm = label.fontMetrics()
label.setText(
fm.elidedText(text, Qt.TextElideMode.ElideRight, max(10, label.width()))
)
def _make_weather_icon(self, weather: str) -> QPixmap:
wname = (weather or "").strip()
img_path = os.path.join(self._weather_dir, f"{wname}.png")
if os.path.exists(img_path):
pm = QPixmap(img_path)
if not pm.isNull():
target = self._icon_label.size()
return pm.scaled(
target.width(),
target.height(),
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
# fallback:透明背景的占位圆
pm = QPixmap(self._icon_label.size())
pm.fill(Qt.GlobalColor.transparent)
p = QPainter(pm)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
p.setPen(QPen(QColor("#888888"), 2))
p.drawEllipse(6, 6, pm.width() - 12, pm.height() - 12)
p.end()
return pm
def update_weather(self, data: dict):
if not data:
return
temp = data.get("temperature", "--")
weather = str(data.get("weather", "") or "")
province = str(data.get("province", "") or "")
city = str(data.get("city", "") or "")
district = str(data.get("district", "") or "")
wind_direction = str(data.get("wind_direction", "") or "")
wind_power = str(data.get("wind_power", "") or "")
# 地点:优先 city,其次 province;如有 district 追加
location = city or province or "--"
if district and district not in location:
location = f"{location}{district}"
# 温度:尽量显示为整数(接口一般是数字)
if temp in ("", None):
temp_text = "--°C"
else:
try:
temp_text = f"{int(float(temp))}°C"
except Exception:
temp_text = f"{temp}°C"
self._temp_label.setText(temp_text)
self._icon_label.setPixmap(self._make_weather_icon(weather))
self._set_elided(self._weather_name_label, weather)
self._set_elided(self._location_label, location)
self._set_elided(self._wind_label, f"{wind_direction}{wind_power}".replace(" ", ""))
def show_loading(self):
self._temp_label.setText("--°C")
self._icon_label.setPixmap(QPixmap(self._icon_label.size()))
self._weather_name_label.setText("获取中…")
self._location_label.setText("")
self._wind_label.setText("")
def _make_tray_icon():
logo = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logo.png")
if os.path.exists(logo):
return QIcon(logo)
px = QPixmap(32, 32)
px.fill(Qt.GlobalColor.transparent)
p = QPainter(px)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
p.setBrush(QBrush(QColor("#4a9eff")))
p.setPen(Qt.PenStyle.NoPen)
p.drawRoundedRect(2, 2, 28, 28, 6, 6)
p.setBrush(QBrush(QColor("white")))
for y in [9, 15, 21]:
p.drawRoundedRect(7, y, 18, 3, 1, 1)
p.end()
return QIcon(px)
class GroupsContainer(QWidget):
"""支持分组拖拽排序的容器"""
def __init__(self, parent=None):
super().__init__(parent)
self.setAcceptDrops(True)
self._indicator_pos = -1 # 指示线位置(像素 y)
def dragEnterEvent(self, event):
if event.mimeData().hasFormat("application/x-group-id"):
event.acceptProposedAction()
else:
event.ignore()
def dragMoveEvent(self, event):
if event.mimeData().hasFormat("application/x-group-id"):
self._indicator_pos = event.position().toPoint().y()
self.update()
event.acceptProposedAction()
def dragLeaveEvent(self, event):
self._indicator_pos = -1
self.update()
def dropEvent(self, event):
self._indicator_pos = -1
self.update()
if not event.mimeData().hasFormat("application/x-group-id"):
event.ignore()
return
gid = int(event.mimeData().data("application/x-group-id").data().decode())
drop_y = event.position().toPoint().y()
# 找到插入位置
layout = self.layout()
insert_idx = layout.count() - 1 # 默认插到最后(stretch 前)
for i in range(layout.count() - 1):
item = layout.itemAt(i)
if item and item.widget():
geo = item.widget().geometry()
if drop_y < geo.center().y():
insert_idx = i
break
# 收集当前顺序,把拖动的移到 insert_idx
ids = []
for i in range(layout.count() - 1):
item = layout.itemAt(i)
if item and item.widget():
ids.append(item.widget().group_data["id"])
if gid in ids:
ids.remove(gid)
ids.insert(min(insert_idx, len(ids)), gid)
from db import database
database.reorder_groups(ids)
# 通知父级刷新
p = self.parent()
while p:
if hasattr(p, "refresh_groups"):
p.refresh_groups()
break
p = p.parent()
event.acceptProposedAction()
def paintEvent(self, event):
super().paintEvent(event)
if self._indicator_pos < 0:
return
p = QPainter(self)
p.setPen(QPen(QColor("#4a9eff"), 2))
p.drawLine(0, self._indicator_pos, self.width(), self._indicator_pos)
p.end()
class PanelWindow(QWidget):
weather_updated = pyqtSignal(dict)
# base_utc_ts: 接口返回的 UTC 时间戳(秒)
# tz_offset_sec: 目标时区相对 UTC 的偏移(秒)
time_synced = pyqtSignal(float, int)
def __init__(self):
super().__init__()
self.setWindowTitle(app_title())
# 默认不置顶;仅图钉开启时与悬浮球一并置顶(见 _apply_pin_window_layer)
self.setWindowFlags(Qt.WindowType.FramelessWindowHint)
# 任务栏/开始菜单图标:优先 ico,其次 png
base_dir = os.path.dirname(os.path.dirname(__file__))
logo_ico = os.path.join(base_dir, "logo.ico")
logo_png = os.path.join(base_dir, "logo.png")
if os.path.exists(logo_ico):
self.setWindowIcon(QIcon(logo_ico))
elif os.path.exists(logo_png):
self.setWindowIcon(QIcon(logo_png))
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setAcceptDrops(True)
self.setMinimumSize(MIN_W, MIN_H)
self.resize(PANEL_W, PANEL_H)
self._persist_w = PANEL_W
self._persist_h = PANEL_H
self._screen_hooked = False
# 让窗口本身也追踪鼠标(不按键也收到 mouseMoveEvent)
self.setMouseTracking(True)
self._anim = None
self._win_drag_pos = None
self._resizing = False
self._resize_edge = None
self._resize_start_global = None
self._resize_start_geo = None
self._pinned = False
self._build_ui()
self._setup_tray()
self._apply_theme()
self._restore_geometry()
self.weather_updated.connect(self._on_weather_updated)
self.time_synced.connect(self._on_time_synced)
self._weather_fetching = False
self._time_base_utc_ts: float = 0.0
self._time_mono_base: float = 0.0
# 默认取本机系统时区;天气接口仅用于定位参考,不再反向改写时区
try:
self._tz_offset_sec: int = int(
datetime.datetime.now().astimezone().utcoffset().total_seconds()
)
except Exception:
self._tz_offset_sec = 8 * 3600
self._time_timer = QTimer(self)
self._time_timer.setInterval(1000)
self._time_timer.timeout.connect(self._refresh_time_label)
self._updater = Updater(self)
self._dock_state = "none"
self._is_collapsed = False
self._is_hiding = False
self._temp_disable_collapse = False
self._dock_timer = QTimer(self)
self._dock_timer.setInterval(200)
self._dock_timer.timeout.connect(self._on_dock_timer_timeout)
self._dock_timer.start()
def showEvent(self, event):
super().showEvent(event)
self._apply_win32_blur(theme.name() == "dark")
if not self._screen_hooked:
wh = self.windowHandle()
if wh is not None:
wh.screenChanged.connect(self._on_screen_changed)
self._screen_hooked = True
# 首次显示时异步获取一次天气
if getattr(self, "_weather_once", False) is False:
self._weather_once = True
self._update_weather_async()
if getattr(self, "_time_once", False) is False:
self._time_once = True
self._update_time_from_ip_async()
def _on_screen_changed(self, screen=None):
"""跨不同 DPI 的显示器拖动时,Qt 可能改变窗口逻辑尺寸,按记录值拉回。"""
if getattr(self, "_body", None) is not None and not self._body.isVisible():
return
w = getattr(self, "_persist_w", PANEL_W)
h = getattr(self, "_persist_h", PANEL_H)
if w >= MIN_W and h >= MIN_H:
self.resize(w, h)
# ── 天气 ─────────────────────────────────────────────
def _update_weather_async(self):
def _worker():
data: dict | None = None
try:
url = "https://uapis.cn/api/v1/misc/weather"
req = urllib.request.Request(
url,
headers={
"User-Agent": "Mozilla/5.0",
"Accept": "application/json",
},
)
with urllib.request.urlopen(req, timeout=5) as resp:
raw = resp.read().decode("utf-8", "replace").strip()
if raw:
data = json.loads(raw)
if not isinstance(data, dict):
data = None
except Exception as e:
# 方便你在控制台看到原因
print("Weather fetch failed:", repr(e))
print("Weather raw (if any):", repr(locals().get("raw", ""))[:200])
data = None
# 线程内不要直接操作 QWidget,改为发信号
self.weather_updated.emit(data or {})
threading.Thread(target=_worker, daemon=True).start()
def _on_weather_updated(self, data: dict):
if not hasattr(self, "weather_box"):
return
self.weather_box.update_weather(data)
self._weather_fetching = False
# 时区不再依赖天气接口(接口失败时旧逻辑会把时区清零,导致时间差 8 小时),
# 统一使用初始化时的系统时区。
if self._time_base_utc_ts > 0:
self._refresh_time_label()
def _refresh_weather(self):
if getattr(self, "_weather_fetching", False):
return
self._weather_fetching = True
try:
self.weather_box.show_loading()
except Exception:
pass
self._update_weather_async()
def _refresh_time_label(self):
if not hasattr(self, "time_label"):
return
if self._time_base_utc_ts <= 0:
return
# 用接口返回的 UTC 基准时间 + monotonic 增量(不受本机时间影响)
delta_sec = _time.monotonic() - self._time_mono_base
utc_now = self._time_base_utc_ts + delta_sec
local_now = utc_now + self._tz_offset_sec
dt = datetime.datetime.fromtimestamp(local_now, tz=datetime.timezone.utc)
self.time_label.setText(dt.strftime("%H:%M:%S"))
self.time_date_label.setText(dt.strftime("%Y-%m-%d"))
def _update_time_from_ip_async(self):
def _ntp_query(server: str, timeout: float) -> float:
# ... (保持原有的 ntp_query 逻辑不变) ...
msg = b"\x1b" + 47 * b"\0"
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.settimeout(timeout)
s.sendto(msg, (server, 123))
data, _ = s.recvfrom(512)
t = struct.unpack("!II", data[40:48])
return t[0] + t[1] / 2**32 - 2208988800.0
def _worker():
# 这里的服务器列表是解决问题的核心:优先国内阿里、腾讯节点
servers = [
"ntp.aliyun.com",
"ntp.tencent.com",
"cn.pool.ntp.org",
"pool.ntp.org",
]
base_utc_ts = 0.0
for srv in servers:
try:
# 尝试连接,超时时间设为 2.5 秒
base_utc_ts = float(_ntp_query(srv, timeout=2.5))
if base_utc_ts > 0:
break # 只要有一个成功,就跳出循环
except Exception:
continue # 失败了换下一个服务器
# 兜底:如果所有服务器都连不上,回退到本机时间
if base_utc_ts <= 0:
base_utc_ts = _time.time()
tz_offset_sec = int(getattr(self, "_tz_offset_sec", 8 * 3600))
self.time_synced.emit(base_utc_ts, tz_offset_sec)
threading.Thread(target=_worker, daemon=True).start()
def _on_time_synced(self, base_utc_ts: float, tz_offset_sec: int):
self._time_base_utc_ts = base_utc_ts
self._tz_offset_sec = tz_offset_sec
self._time_mono_base = _time.monotonic()
self._refresh_time_label()
if not self._time_timer.isActive():
self._time_timer.start()
# ── UI ──────────────────────────────────────────────
def _get_quick_actions(self):
"""返回快捷动作列表,悬浮球右键菜单和左侧快捷栏共用同一份。
每项: (tooltip, qtawesome_icon, callback)
排除重启/退出。"""
return [
("微信多开", "fa5b.weixin", self._open_wechat_multi),
("管理员运行 CMD", "fa5s.terminal", self._open_admin_cmd),
("管理员运行 PowerShell", "fa5b.windows", self._open_admin_powershell),
("打开默认浏览器", "fa5s.globe", self._open_default_browser),
("解除占用", "fa5s.unlock", self._open_unlocker),
("硬件温度", "fa5s.thermometer-half", self._open_temperature),
]
def _build_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
self.container = QWidget()
self.container.setObjectName("container")
self.container.setMouseTracking(True)
# 横向:左侧快捷栏 + 右侧主内容
h_layout = QHBoxLayout(self.container)
h_layout.setContentsMargins(0, 0, 0, 0)
h_layout.setSpacing(0)
# ── 左侧快捷栏
self._quick_bar = QWidget()
self._quick_bar.setObjectName("quick_bar")
self._quick_bar.setFixedWidth(50)
# 快捷栏上的按钮会吃掉鼠标事件;为保证边缘 resize 可用,快捷栏自身也追踪鼠标并接入事件过滤
self._quick_bar.setMouseTracking(True)
self._quick_bar.installEventFilter(self)
quick_layout = QVBoxLayout(self._quick_bar)
quick_layout.setContentsMargins(0, 10, 0, 10)
quick_layout.setSpacing(6)
quick_layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter)
self._quick_btns = []
for tooltip, icon_name, callback in self._get_quick_actions():
btn = QPushButton()
btn.setFixedSize(34, 34)
_install_themed_tooltip(btn, tooltip)
btn.setStyleSheet("border:none; background:transparent; border-radius:4px;")
btn.setMouseTracking(True)
btn.installEventFilter(self)
try:
btn.setIcon(qta.icon(icon_name, color="#888"))
btn.setIconSize(QSize(16, 16))
except Exception:
btn.setText(tooltip[0])
btn.clicked.connect(callback)
quick_layout.addWidget(btn, alignment=Qt.AlignmentFlag.AlignHCenter)
self._quick_btns.append((btn, icon_name))
quick_layout.addStretch()
h_layout.addWidget(self._quick_bar)
# ── 右侧主内容
right_widget = QWidget()
right_widget.setMouseTracking(True)
inner = QVBoxLayout(right_widget)
inner.setContentsMargins(8, 10, 8, 8)
inner.setSpacing(6)
h_layout.addWidget(right_widget)
# ── 标题栏(仅此处可拖动窗口,避免分组内框选时带动面板)
self._title_bar = QWidget()
self._title_bar.setMouseTracking(True)
title_bar = QHBoxLayout(self._title_bar)
title_bar.setContentsMargins(0, 0, 0, 0)
title_bar.setSpacing(6)
self.pin_btn = QPushButton()
self.pin_btn.setFixedSize(26, 26)
self.pin_btn.setToolTip(
"固定:面板与悬浮球置顶;开启后最小化只收缩内容,不变悬浮球"
)
self.pin_btn.setStyleSheet("border:none; background:transparent;")
self.pin_btn.clicked.connect(self._toggle_pin)
# 程序名称
self.app_title = QLabel(app_title())
self.app_title.setStyleSheet(
"font-size:13px; font-weight:bold; background:transparent;"
)
self._min_btn = QPushButton()
self._min_btn.setFixedSize(26, 26)
self._min_btn.setToolTip("最小化")
self._min_btn.setStyleSheet("border:none; background:transparent;")
self._min_btn.clicked.connect(self._on_min_click)
title_bar.addWidget(self.pin_btn)
title_bar.addWidget(self.app_title)
title_bar.addStretch()
title_bar.addWidget(self._min_btn)
inner.addWidget(self._title_bar)
# ── 顶部信息栏:左天气 + 右网络时间
self.weather_bar = QWidget()
self.weather_bar.setFixedHeight(80) # 强制整个天气栏高度对齐 WeatherBox
wb_layout = QHBoxLayout(self.weather_bar)
wb_layout.setContentsMargins(5, 0, 5, 0) # 上下边距设为 0,消除空隙
wb_layout.setSpacing(10)
self.weather_box = WeatherBox()
wb_layout.addWidget(self.weather_box)
self.weather_refresh_btn = QPushButton("刷新")
self.weather_refresh_btn.setFixedSize(24, 24)
self.weather_refresh_btn.setText("")
_install_themed_tooltip(self.weather_refresh_btn, "刷新天气")
self.weather_refresh_btn.setStyleSheet(
"border:none; background:transparent; border-radius:6px;"
)
self.weather_refresh_btn.clicked.connect(self._refresh_weather)
wb_layout.addWidget(self.weather_refresh_btn)
wb_layout.addStretch()
self.time_box = QWidget()
tb_layout = QVBoxLayout(self.time_box)
tb_layout.setContentsMargins(0, 0, 0, 0)
tb_layout.setSpacing(0)
self.time_title_label = QLabel("网络时间")
self.time_title_label.setStyleSheet("font-size:11px; color:#888;")
self.time_title_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop
)
self.time_label = QLabel("--:--:--")
self.time_label.setStyleSheet("font-weight:bold; font-size:18px; color:#ddd;")
self.time_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self.time_date_label = QLabel("--")
self.time_date_label.setStyleSheet("font-size:11px; color:#888;")
self.time_date_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
tb_layout.addWidget(self.time_title_label)
tb_layout.addWidget(self.time_label)
tb_layout.addWidget(self.time_date_label)
wb_layout.addWidget(self.time_box)
inner.addWidget(self.weather_bar)
# ── 搜索框 + 文字按钮(同一行)
search_row = QHBoxLayout()
search_row.setContentsMargins(0, 0, 0, 0)
search_row.setSpacing(6)
self.search_box = QLineEdit()
self.search_box.setPlaceholderText("搜索程序...")
self.search_box.setClearButtonEnabled(True)
self.search_box.textChanged.connect(self._on_search)
# 前置搜索图标(随主题在 _apply_theme 中重新着色)
self._search_action = self.search_box.addAction(
qta.icon("fa5s.search", color="#888"),
QLineEdit.ActionPosition.LeadingPosition,
)
self.add_group_btn = QPushButton("添加分组")
self.add_group_btn.setFixedHeight(32)
self.add_group_btn.setToolTip("新建分组")
self.add_group_btn.clicked.connect(self._add_group)
self.add_folder_btn = QPushButton("添加文件夹")
self.add_folder_btn.setFixedHeight(32)
self.add_folder_btn.setToolTip("读取文件夹,自动创建分组")
self.add_folder_btn.clicked.connect(self._add_from_folder)
search_row.addWidget(self.search_box)
search_row.addWidget(self.add_group_btn)
search_row.addWidget(self.add_folder_btn)
# ── 可收缩的内容区(搜索+分组+底部栏)
self._body = QWidget()
body_layout = QVBoxLayout(self._body)
body_layout.setContentsMargins(0, 0, 0, 0)
body_layout.setSpacing(6)
body_layout.addLayout(search_row)
# ── 分组滚动区
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.groups_container = GroupsContainer()
self.groups_container.setStyleSheet("background:transparent;")
self.groups_layout = QVBoxLayout(self.groups_container)
self.groups_layout.setContentsMargins(0, 0, 0, 0)
self.groups_layout.setSpacing(6)
self.groups_layout.addStretch()
self.scroll.setWidget(self.groups_container)
body_layout.addWidget(self.scroll)
# ── 底部工具栏
self.bottom_bar = QWidget()
self.bottom_bar.setObjectName("bottom_bar")
self.bottom_bar.setFixedHeight(36)
bottom_layout = QHBoxLayout(self.bottom_bar)
bottom_layout.setContentsMargins(6, 0, 6, 0)
bottom_layout.setSpacing(4)
self.menu_btn = QPushButton()
self.menu_btn.setFixedSize(28, 28)
_install_themed_tooltip(self.menu_btn, "菜单")
self.menu_btn.setStyleSheet(
"border:none; background:transparent; border-radius:4px;"
)
self.menu_btn.clicked.connect(self._show_bottom_menu)
self.theme_btn = QPushButton()
self.theme_btn.setFixedSize(28, 28)
_install_themed_tooltip(self.theme_btn, "切换亮/暗主题")
self.theme_btn.setStyleSheet(
"border:none; background:transparent; border-radius:4px;"
)
self.theme_btn.clicked.connect(self._toggle_theme)
self.settings_btn = QPushButton()
self.settings_btn.setFixedSize(28, 28)
_install_themed_tooltip(self.settings_btn, "设置")
self.settings_btn.setStyleSheet(
"border:none; background:transparent; border-radius:4px;"
)
self.settings_btn.clicked.connect(self._show_settings)
self.quit_btn = QPushButton()
self.quit_btn.setFixedSize(28, 28)
_install_themed_tooltip(self.quit_btn, "退出程序")
self.quit_btn.setStyleSheet(
"border:none; background:transparent; border-radius:4px;"
)
self.quit_btn.clicked.connect(self._quit_application)
bottom_layout.addWidget(self.menu_btn)
bottom_layout.addStretch()
self.update_btn = QPushButton()
self.update_btn.setFixedSize(28, 28)
_install_themed_tooltip(self.update_btn, "检查更新")
self.update_btn.setStyleSheet("border:none; background:transparent; border-radius:4px;")
self.update_btn.clicked.connect(lambda: self._updater.check(silent_if_latest=False))
bottom_layout.addWidget(self.update_btn)
bottom_layout.addWidget(self.theme_btn)
bottom_layout.addWidget(self.settings_btn)
bottom_layout.addWidget(self.quit_btn)
body_layout.addWidget(self.bottom_bar)
inner.addWidget(self._body)
root.addWidget(self.container)
# eventFilter 只用于 resize 拖拽,不拦截子 widget 的 drop
self.container.installEventFilter(self)
self.refresh_groups()
def _apply_theme(self):
t = theme.current()
is_dark = theme.name() == "dark"
ic = "#cccccc" if is_dark else "#555555"
self._apply_tooltip_theme(t, is_dark)
self._apply_win32_blur(is_dark)
self.container.setStyleSheet(
f"""
QWidget#container {{
background: {t['panel_bg']};
border-radius: 12px;
border: 1px solid {t['panel_border']};
}}
"""
)
self.search_box.setStyleSheet(
f"""
QLineEdit {{
background: {t['search_bg']};
border: 1px solid {t['search_border']};
border-radius: 5px;
color: {t['search_color']};
padding: 5px 10px;
font-size: 12px;
}}
QLineEdit:focus {{ border-color: {t['search_focus']}; }}
"""
)
txt_btn_style = f"""
QPushButton {{
border: 1px solid {t['search_border']};
border-radius: 5px;
color: {t['btn_color']};
background: {t['search_bg']};
font-size: 11px;
padding: 0 8px;
}}
QPushButton:hover {{ background: {t['header_hover']}; color: {t['btn_hover']}; }}
"""
self.add_group_btn.setStyleSheet(txt_btn_style)
self.add_folder_btn.setStyleSheet(txt_btn_style)
# 文字按钮前置图标(随主题着色)
try:
self.add_group_btn.setIcon(qta.icon("fa5s.plus", color=ic))
self.add_group_btn.setIconSize(QSize(11, 11))
self.add_folder_btn.setIcon(qta.icon("fa5s.folder-plus", color=ic))
self.add_folder_btn.setIconSize(QSize(11, 11))
except Exception:
pass
# 搜索图标随主题着色
try:
self._search_action.setIcon(qta.icon("fa5s.search", color=ic))
except Exception:
pass
self.scroll.setStyleSheet(
f"""
QScrollArea {{ border:none; background:transparent; }}
QScrollBar:vertical {{ background:transparent; width:4px; }}
QScrollBar::handle:vertical {{ background:{t['scrollbar']}; border-radius:2px; }}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height:0; }}
"""
)
self.pin_btn.setIcon(
qta.icon("fa5s.thumbtack", color="#f90" if self._pinned else ic)
)
self.pin_btn.setIconSize(QSize(13, 13))
self._min_btn.setIcon(qta.icon("fa5s.minus", color=ic))
self._min_btn.setIconSize(QSize(13, 13))
# 标题栏按钮悬停反馈
title_btn_style = (
f"QPushButton {{ border:none; background:transparent; border-radius:5px; }}"
f"QPushButton:hover {{ background:{t['header_hover']}; }}"
)
self.pin_btn.setStyleSheet(title_btn_style)
self._min_btn.setStyleSheet(title_btn_style)
self.app_title.setStyleSheet(
f"font-size:13px; font-weight:bold; background:transparent; color:{t['search_color']};"
)
# 顶部天气栏
self.weather_bar.setStyleSheet("background:transparent; border-radius:6px;")
self.weather_box.set_theme_colors(t["search_color"])
if hasattr(self, "weather_refresh_btn"):
self.weather_refresh_btn.setStyleSheet(
f"QPushButton {{ border:none; background:transparent; border-radius:6px; }}"
f"QPushButton:hover {{ background:{t['header_hover']}; }}"
)
# 刷新按钮图标跟随主题颜色
try:
self.weather_refresh_btn.setIcon(
qta.icon("fa5s.sync-alt", color=t["search_color"])
)
self.weather_refresh_btn.setIconSize(QSize(14, 14))
except Exception:
pass
# 网络时间文字
if hasattr(self, "time_label"):
self.time_label.setStyleSheet(
f"font-weight:bold; font-size:18px; color:{t['search_color']};"
)
if hasattr(self, "time_date_label"):
self.time_date_label.setStyleSheet(
f"font-size:11px; color:{t['search_color']};"
)
if hasattr(self, "time_title_label"):
self.time_title_label.setStyleSheet("font-size:11px; color: #888;")
# 左侧快捷栏
bar_side_bg = "rgba(0,0,0,20)" if is_dark else "rgba(0,0,0,6)"
self._quick_bar.setStyleSheet(
f"""
QWidget#quick_bar {{
background: {bar_side_bg};
border: 1px solid {t['panel_border']};
border-radius: 12px;
}}
QPushButton {{ border:none; background:transparent; border-radius:4px; }}
QPushButton:hover {{ background:{t['header_hover']}; }}
"""
)
for btn, icon_name in self._quick_btns:
try:
btn.setIcon(qta.icon(icon_name, color=ic))
btn.setIconSize(QSize(14, 14))
except Exception:
pass
# 底部工具栏
bar_bg2 = "rgba(0,0,0,30)" if is_dark else "rgba(0,0,0,8)"
self.bottom_bar.setStyleSheet(
f"""
QWidget#bottom_bar {{
background: {bar_bg2};
border-radius: 0 0 12px 12px;
border-top: 1px solid {t['panel_border']};
}}
QPushButton {{ border:none; background:transparent; border-radius:4px; }}
QPushButton:hover {{ background:{t['header_hover']}; }}
"""
)
self.theme_btn.setIcon(
qta.icon("fa5s.sun" if is_dark else "fa5s.moon", color=ic)
)
self.theme_btn.setIconSize(QSize(14, 14))
self.update_btn.setIcon(qta.icon("fa5s.sync-alt", color=ic))
self.update_btn.setIconSize(QSize(14, 14))
self.menu_btn.setIcon(qta.icon("fa5s.bars", color=ic))
self.menu_btn.setIconSize(QSize(14, 14))
self.settings_btn.setIcon(qta.icon("fa5s.cog", color=ic))
self.settings_btn.setIconSize(QSize(14, 14))
self.quit_btn.setIcon(qta.icon("fa5s.sign-out-alt", color=ic))
self.quit_btn.setIconSize(QSize(14, 14))
# 托盘菜单跟随主题
if getattr(self, "_tray_menu", None) is not None:
self._tray_menu.setStyleSheet(self._bottom_menu_stylesheet())
for i in range(self.groups_layout.count() - 1):
item = self.groups_layout.itemAt(i)
if item and item.widget():
item.widget()._apply_theme()
def _apply_tooltip_theme(self, t: dict, is_dark: bool):
"""让所有控件 tooltip 跟随主题(含快捷栏按钮)。"""
from PyQt6.QtGui import QPalette, QColor
from PyQt6.QtWidgets import QToolTip
app = QApplication.instance()
if app is None:
return
bg = "#2d2d2d" if is_dark else "#ffffff"
fg = "#eeeeee" if is_dark else "#111111"
bd = t.get("menu_border") or t.get("search_border") or "#666"
# 通过 palette 强制覆盖 tooltip 颜色(Windows 上 stylesheet 单独不够)
palette = app.palette()
palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(bg))
palette.setColor(QPalette.ColorRole.ToolTipText, QColor(fg))
app.setPalette(palette)
# stylesheet 同步设置(双保险)
start = "/*TOOLTIP_THEME_START*/"
end = "/*TOOLTIP_THEME_END*/"
tooltip_css = (
f"{start}\n"
"QToolTip {\n"
f" background-color: {bg};\n"
f" color: {fg};\n"
f" border: 1px solid {bd};\n"
" border-radius: 6px;\n"
" padding: 6px 8px;\n"
" font-size: 12px;\n"
" opacity: 240;\n"
"}\n"
f"{end}\n"
)
ss = app.styleSheet() or ""
if start in ss and end in ss:
pre = ss.split(start, 1)[0]
post = ss.split(end, 1)[1]
ss = pre + tooltip_css + post
else:
ss = (ss + "\n" if ss and not ss.endswith("\n") else ss) + tooltip_css
app.setStyleSheet(ss)
def _quit_application(self):
ret = dialog_style.question(
self,
"退出",
"确认退出吗?",
default_button=QMessageBox.StandardButton.No,
)
if ret == QMessageBox.StandardButton.Yes:
QApplication.quit()
def _restart_application(self):
import subprocess
subprocess.Popen([sys.executable] + sys.argv)
QApplication.quit()
def _open_admin_cmd(self):
import ctypes
ctypes.windll.shell32.ShellExecuteW(None, "runas", "cmd.exe", None, None, 1)
def _open_admin_powershell(self):
import ctypes
ctypes.windll.shell32.ShellExecuteW(None, "runas", "powershell.exe", None, None, 1)
def _open_default_browser(self):
import webbrowser
webbrowser.open("https://www.baidu.com")
def _open_unlocker(self):
try:
from ui.unlocker import UnlockDialog
except Exception as e:
dialog_style.warning(self, "无法打开解除占用工具", f"加载模块失败:{e}")
return
dlg = UnlockDialog(self)
dlg.exec()
def _open_temperature(self):
try:
from ui.temperature import TemperatureWindow
except Exception as e:
dialog_style.warning(self, "无法打开硬件温度", f"加载模块失败:{e}")
return
# 非模态独立窗口:可与其它功能窗口同时打开、自由切换
win = getattr(self, "_temperature_win", None)
if win is None:
win = TemperatureWindow(self)
self._temperature_win = win
win.show()
win.raise_()
win.activateWindow()
def _open_wechat_multi(self):
from ui.wechat_multi import WechatMultiDialog
dlg = WechatMultiDialog(self)
dlg.exec()
def _toggle_theme(self):
theme.set_theme("light" if theme.name() == "dark" else "dark")
self._apply_theme()
# 若设置窗口已打开,同步更新其主题
if hasattr(self, "_settings_win") and self._settings_win.isVisible():
try:
self._settings_win._apply_theme()
except Exception:
pass
# 温度窗口同样跟随主题
if hasattr(self, "_temperature_win") and self._temperature_win.isVisible():
try:
self._temperature_win._apply_theme()
except Exception:
pass
def _show_settings(self):
from ui.settings_window import SettingsWindow
if hasattr(self, "_settings_win") and self._settings_win.isVisible():
self._settings_win.raise_()
self._settings_win.activateWindow()
return
self._settings_win = SettingsWindow(self)
self._settings_win.show()
def _show_bottom_menu(self):
menu = QMenu(self)
menu.setStyleSheet(self._bottom_menu_stylesheet())
menu.addAction(self._make_doubao_icon(), "豆包会话").triggered.connect(
self._open_doubao_session
)
menu.addAction(
self._make_round_menu_icon("D", "#4d6bfe"), "DeepSeek"
).triggered.connect(
lambda: self._open_url_in_browser(
"https://chat.deepseek.com/", "DeepSeek"
)
)
menu.addAction(
self._make_round_menu_icon("G", "#4285f4"), "Gemini"
).triggered.connect(
lambda: self._open_url_in_browser(
"https://gemini.google.com/", "Gemini"
)
)
# 菜单贴近按钮下方显示,避免漂移到不自然位置
pos = self.menu_btn.mapToGlobal(self.menu_btn.rect().bottomLeft())
menu.exec(pos)
def _bottom_menu_stylesheet(self) -> str:
t = theme.current()
return f"""
QMenu {{
background: {t['menu_bg']};
color: {t['menu_color']};
border: 1px solid {t['menu_border']};
border-radius: 8px;
padding: 4px;
}}
QMenu::item {{
padding: 6px 14px;
border-radius: 5px;
background: transparent;
}}
QMenu::item:selected {{
background: {t['menu_selected']};
}}
QMenu::separator {{
height: 1px;
background: {t['menu_border']};
margin: 4px 8px;
}}
"""
def _make_doubao_icon(self) -> QIcon:
return self._make_round_menu_icon("豆", "#3b82f6", font_pt=9)
def _make_round_menu_icon(
self, letter: str, bg_hex: str, *, font_pt: int = 9
) -> QIcon:
pm = QPixmap(18, 18)
pm.fill(Qt.GlobalColor.transparent)
p = QPainter(pm)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
p.setBrush(QBrush(QColor(bg_hex)))
p.setPen(Qt.PenStyle.NoPen)
p.drawEllipse(1, 1, 16, 16)
p.setPen(QPen(QColor("white")))
f = p.font()
f.setPointSize(font_pt)
f.setBold(True)
p.setFont(f)
p.drawText(pm.rect(), int(Qt.AlignmentFlag.AlignCenter), letter)
p.end()
return QIcon(pm)
def _open_url_in_browser(self, url: str, title: str) -> None:
try:
ok = webbrowser.open(url)
except Exception as e:
dialog_style.warning(
self, "打开失败", f"无法在默认浏览器中打开{title}:{e}"
)
return
if not ok:
dialog_style.warning(
self,
"打开失败",
f"系统未注册可用的默认浏览器,请手动在浏览器中打开{title}:\n{url}",
)
def _open_doubao_session(self):
"""用系统默认浏览器打开豆包会话页。"""
self._open_url_in_browser("https://www.doubao.com/chat/", "豆包")
def refresh_groups(self):
# 记录当前折叠状态
collapsed_ids = set()
for i in range(self.groups_layout.count() - 1):
item = self.groups_layout.itemAt(i)
if item and item.widget():
gw = item.widget()
if gw.collapsed:
collapsed_ids.add(gw.group_data["id"])
while self.groups_layout.count() > 1:
item = self.groups_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
for gdata in database.get_groups():
gw = GroupWidget(gdata)
gw.request_refresh_all.connect(self.refresh_groups)
# 恢复折叠状态
if gdata["id"] in collapsed_ids:
gw.collapsed = True
gw.flow.setVisible(False)
t = theme.current()
ic = "#cccccc" if theme.name() == "dark" else "#555555"
import qtawesome as _qta
gw.toggle_icon.setPixmap(
_qta.icon("fa5s.chevron-right", color=ic).pixmap(14, 14)
)
self.groups_layout.insertWidget(self.groups_layout.count() - 1, gw)
def reset_groups_and_items(self):
"""清空所有分组与程序,并刷新界面。"""
database.reset_groups_and_items(recreate_default_group=True)
# 清空搜索关键字,确保刷新后可见全部内容
try:
self.search_box.setText("")
except Exception:
pass
self.refresh_groups()
def _add_group(self):
name, ok = dialog_style.get_text(self, "新建分组", "分组名称:")
if ok and name.strip():
database.add_group(name.strip())
self.refresh_groups()
def _add_from_folder(self):
folder = dialog_style.get_existing_directory(self, "选择文件夹")
if not folder:
return
# 统一路径分隔符
folder = os.path.normpath(folder)
group_name = os.path.basename(folder)
gid = database.add_group(group_name, folder_path=folder)
# 文件夹分组直接读目录,不写入 items 表
count = sum(
1 for f in os.listdir(folder) if os.path.isfile(os.path.join(folder, f))
)
self.refresh_groups()
dialog_style.information(
self,
"完成",
f"已创建分组「{group_name}」,共 {count} 个文件(实时读取)。",
)
def _sync_pin_dependent_ui(self):
"""图钉图标、最小化提示、置顶标志(面板 + 悬浮球)。"""
ic = "#cccccc" if theme.name() == "dark" else "#555555"
self.pin_btn.setIcon(
qta.icon("fa5s.thumbtack", color="#f90" if self._pinned else ic)
)
self.pin_btn.setIconSize(QSize(13, 13))
if self._pinned:
self._min_btn.setToolTip("收缩内容区(固定模式)")
else:
self._min_btn.setToolTip("最小化到悬浮球")
self._apply_pin_window_layer()
def _apply_pin_window_layer(self):
"""图钉开 → 面板与悬浮球 WindowStaysOnTop;关 → 普通叠放,不挡其他程序。"""
on_top = self._pinned
if getattr(self, "_pin_top_applied", None) is not on_top:
self._pin_top_applied = on_top
was_visible = self.isVisible()
geo = self.geometry()
flags = Qt.WindowType.FramelessWindowHint
if on_top:
flags |= Qt.WindowType.WindowStaysOnTopHint
self.setWindowFlags(flags)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.setAcceptDrops(True)
logo_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logo.png")
if os.path.exists(logo_path):
self.setWindowIcon(QIcon(logo_path))
if was_visible:
self.setGeometry(geo)
self.show()
self._apply_win32_blur(theme.name() == "dark")
ball = getattr(self, "_ball_ref", None)
if ball is not None:
ball.set_stays_on_top(on_top)
def _toggle_pin(self):
self._pinned = not self._pinned
self._sync_pin_dependent_ui()
if self._pinned:
if getattr(self, "_is_collapsed", False):
self._expand_panel()
self._dock_state = "none"
def _on_min_click(self):
"""图钉开启时收缩内容区,否则最小化到悬浮球"""
if self._pinned:
if self._body.isVisible():
self._collapse_body()
else:
self._expand_body()
else:
self.minimize_to_ball()
def _collapse_body(self):
"""收缩:隐藏内容区,窗口缩到只剩标题栏高度"""
self._body.hide()
self.weather_bar.hide()
self._quick_bar.hide()
self._expanded_height = self.height()
title_h = self._title_bar.sizeHint().height() + 20
self.setFixedHeight(title_h)
ic = "#cccccc" if theme.name() == "dark" else "#555555"
self._min_btn.setIcon(qta.icon("fa5s.chevron-down", color=ic))
self._min_btn.setIconSize(QSize(13, 13))
def _expand_body(self):
"""展开:恢复内容区和窗口高度"""
self.weather_bar.show()
self._body.show()
self._quick_bar.show()
h = getattr(self, "_expanded_height", 520)
self.setMinimumHeight(MIN_H)
self.setMaximumHeight(16777215)
self.resize(self.width(), h)
self._persist_w = self.width()
self._persist_h = h
ic = "#cccccc" if theme.name() == "dark" else "#555555"
self._min_btn.setIcon(qta.icon("fa5s.minus", color=ic))
self._min_btn.setIconSize(QSize(13, 13))
def _save_geometry(self):
"""把当前位置和尺寸写入数据库"""
if getattr(self, "_is_collapsed", False):
return
g = self.geometry()
database.set_setting("panel_x", str(g.x()))
database.set_setting("panel_y", str(g.y()))
# 收缩为仅标题栏时高度变小,勿把临时高度写入库/覆盖跨屏用的 persist
if getattr(self, "_body", None) is not None and not self._body.isVisible():
return
self._persist_w = g.width()
self._persist_h = g.height()
database.set_setting("panel_w", str(g.width()))
database.set_setting("panel_h", str(g.height()))
self._check_dock_state()
def _restore_geometry(self):
"""从数据库恢复上次的位置、尺寸和透明度"""
try:
x = int(database.get_setting("panel_x", ""))
y = int(database.get_setting("panel_y", ""))
w = int(database.get_setting("panel_w", str(PANEL_W)))
h = int(database.get_setting("panel_h", str(PANEL_H)))
# 用保存坐标所在屏幕来限制范围,支持多显示器
screen_obj = QApplication.screenAt(QPoint(x, y))
if screen_obj is None:
screen_obj = QApplication.primaryScreen()
screen = screen_obj.availableGeometry()
x = max(screen.left(), min(x, screen.right() - w))
y = max(screen.top(), min(y, screen.bottom() - h))
self._persist_w = w
self._persist_h = h
self.setGeometry(x, y, w, h)
except (ValueError, TypeError):
pass
# 恢复透明度
try:
opacity = int(database.get_setting("panel_opacity", "100"))
self.setWindowOpacity(max(30, min(100, opacity)) / 100)
except (ValueError, TypeError):
pass
self._check_dock_state()
def _on_search(self, keyword):
for i in range(self.groups_layout.count() - 1):
item = self.groups_layout.itemAt(i)
if item and item.widget():
item.widget().filter(keyword)
# ── 显示/隐藏 ────────────────────────────────────────
def show_near(self, ball_pos: QPoint, ball_size: int):
self._is_hiding = False
if getattr(self, "_dock_state", "none") != "none":
self._is_collapsed = False
self._temp_disable_collapse = True
# 从窗口当前位置的屏幕中获取大小,恢复到边缘贴靠展开位置
screen_obj = QApplication.screenAt(self.pos())
if screen_obj is None:
screen_obj = QApplication.primaryScreen()
screen = screen_obj.availableGeometry()
x, y, w, h = self.x(), self.y(), self.width(), self.height()
# 计算展开时的最终坐标
if self._dock_state == "top":
y = screen.top()
elif self._dock_state == "left":
x = screen.left()
elif self._dock_state == "right":
x = screen.right() - w
# 进场滑出偏移方向
offset_x, offset_y = x, y
if self._dock_state == "top":
offset_y = y - 30
elif self._dock_state == "left":
offset_x = x - 30
elif self._dock_state == "right":
offset_x = x + 30
self.move(offset_x, offset_y)
self.setWindowOpacity(0)
self.show()
self.raise_()
if hasattr(self, "_ball_ref"):
self._ball_ref.hide()
if self._anim:
self._anim.stop()
self._anim = QPropertyAnimation(self, b"windowOpacity")
self._anim.setDuration(ANIM_MS)
self._anim.setStartValue(0.0)
self._anim.setEndValue(1.0)
self._anim.start()
self._anim2 = QPropertyAnimation(self, b"pos")
self._anim2.setDuration(ANIM_MS)
self._anim2.setEasingCurve(QEasingCurve.Type.OutCubic)
self._anim2.setStartValue(QPoint(offset_x, offset_y))
self._anim2.setEndValue(QPoint(x, y))
self._anim2.finished.connect(self._save_geometry)
self._anim2.start()
return
# 根据球所在屏幕来定位 panel,避免跑到主屏幕
screen_obj = QApplication.screenAt(ball_pos)
if screen_obj is None:
screen_obj = QApplication.primaryScreen()
screen = screen_obj.availableGeometry()
pw, ph = self.width(), self.height()
bx, by = ball_pos.x(), ball_pos.y()
x = bx - pw - 8
if x < screen.left():
x = bx + ball_size + 8
y = max(screen.top(), min(by + ball_size // 2 - ph // 2, screen.bottom() - ph))
self._is_collapsed = False
self._temp_disable_collapse = True
self.move(x, y + 30)
self.setWindowOpacity(0)
self.show()
self.raise_()
if hasattr(self, "_ball_ref"):
self._ball_ref.hide()
if self._anim:
self._anim.stop()
self._anim = QPropertyAnimation(self, b"windowOpacity")
self._anim.setDuration(ANIM_MS)
self._anim.setStartValue(0.0)
self._anim.setEndValue(1.0)
self._anim.start()
self._anim2 = QPropertyAnimation(self, b"pos")
self._anim2.setDuration(ANIM_MS)
self._anim2.setEasingCurve(QEasingCurve.Type.OutCubic)
self._anim2.setStartValue(QPoint(x, y + 30))
self._anim2.setEndValue(QPoint(x, y))
self._anim2.finished.connect(self._save_geometry)
self._anim2.start()
def hide_panel(self):
self._is_hiding = True
if self._anim:
self._anim.stop()
self._anim = QPropertyAnimation(self, b"windowOpacity")
self._anim.setDuration(ANIM_MS)
self._anim.setStartValue(1.0)
self._anim.setEndValue(0.0)
def _on_hide_finished():
self.hide()
self._is_hiding = False
self._anim.finished.connect(_on_hide_finished)
self._anim.start()
def is_really_visible(self) -> bool:
return self.isVisible() and not getattr(self, "_is_hiding", False)
def minimize_to_ball(self):
self.hide_panel()
if hasattr(self, "_ball_ref"):
self._ball_ref.place_on_screen_of(self)
self._ball_ref.show()
self._ball_ref.setWindowOpacity(1.0)
def toggle_near(self, ball_pos: QPoint, ball_size: int):
if self.isVisible():
if not self._pinned:
self.minimize_to_ball()
else:
self.show_near(ball_pos, ball_size)
# ── 边缘检测 ─────────────────────────────────────────
def _edge_at(self, p: QPoint):
x, y, w, h = p.x(), p.y(), self.width(), self.height()
m = RESIZE_M
l = x <= m
r = x >= w - m
t = y <= m
b = y >= h - m
if b and r:
return "br"
if b and l:
return "bl"
if t and r:
return "tr"
if t and l:
return "tl"
if r:
return "r"
if l:
return "l"
if b:
return "b"
if t:
return "t"
return None
_EDGE_CUR = {
"r": Qt.CursorShape.SizeHorCursor,
"l": Qt.CursorShape.SizeHorCursor,
"b": Qt.CursorShape.SizeVerCursor,
"t": Qt.CursorShape.SizeVerCursor,
"br": Qt.CursorShape.SizeFDiagCursor,
"tl": Qt.CursorShape.SizeFDiagCursor,
"bl": Qt.CursorShape.SizeBDiagCursor,
"tr": Qt.CursorShape.SizeBDiagCursor,
}
def _update_cursor(self, global_pos: QPoint):
"""根据全局坐标更新光标形状(不依赖事件来源 widget)"""
local = self.mapFromGlobal(global_pos)
edge = self._edge_at(local)
self.setCursor(self._EDGE_CUR.get(edge, Qt.CursorShape.ArrowCursor))
def _do_resize(self, gp: QPoint):
d = gp - self._resize_start_global
g = self._resize_start_geo
x, y, w, h = g.x(), g.y(), g.width(), g.height()
e = self._resize_edge
if "r" in e:
w = max(MIN_W, w + d.x())
if "l" in e:
nw = max(MIN_W, w - d.x())
x += w - nw
w = nw
if "b" in e:
h = max(MIN_H, h + d.y())
if "t" in e:
nh = max(MIN_H, h - d.y())
y += h - nh
h = nh
self.setGeometry(x, y, w, h)
def _container_pos_is_title_bar(self, pos_in_container: QPoint) -> bool:
"""仅标题栏区域允许拖动整个面板。"""
w = self.container.childAt(pos_in_container)
p = w
while p:
if p is self._title_bar:
return True
p = p.parentWidget()
return False
# ── eventFilter:处理 container 上的 resize/drag ─────
def eventFilter(self, obj, event):
is_quick = (
obj is getattr(self, "_quick_bar", None)
or (hasattr(self, "_quick_bar") and isinstance(obj, QPushButton) and obj.parentWidget() is self._quick_bar)
)
if obj is self.container or is_quick:
et = event.type()
# drop 事件放行给子 widget
if et in (
QEvent.Type.DragEnter,
QEvent.Type.DragMove,
QEvent.Type.Drop,
QEvent.Type.DragLeave,
):
return False
if (
et == QEvent.Type.MouseButtonPress
and event.button() == Qt.MouseButton.LeftButton
):
local = self.mapFromGlobal(event.globalPosition().toPoint())
edge = self._edge_at(local)
if edge:
self._resizing = True
self._resize_edge = edge
self._resize_start_global = event.globalPosition().toPoint()
self._resize_start_geo = self.geometry()
return True
# 快捷栏/按钮区域不允许拖动窗口(只保留边缘 resize)
if obj is self.container:
pos_c = self.container.mapFrom(self, local)
if not self._container_pos_is_title_bar(pos_c):
return False
self._win_drag_pos = (
event.globalPosition().toPoint() - self.frameGeometry().topLeft()
)
elif et == QEvent.Type.MouseMove:
gp = event.globalPosition().toPoint()
if self._resizing:
self._do_resize(gp)
return True
if event.buttons() & Qt.MouseButton.LeftButton and self._win_drag_pos:
self._dock_state = "none"
self._is_collapsed = False
self.move(gp - self._win_drag_pos)
return True
# 悬停时更新光标
self._update_cursor(gp)
elif et == QEvent.Type.MouseButtonRelease:
self._resizing = False
self._resize_edge = None
self._win_drag_pos = None
self._update_cursor(event.globalPosition().toPoint())
self._check_dock_state()
self._save_geometry()
return super().eventFilter(obj, event)
# ── PanelWindow 自身的鼠标事件(鼠标在 container 外时)
def mouseMoveEvent(self, event):
if self._resizing:
self._do_resize(event.globalPosition().toPoint())
return
if event.buttons() & Qt.MouseButton.LeftButton and self._win_drag_pos:
self._dock_state = "none"
self._is_collapsed = False
self.move(event.globalPosition().toPoint() - self._win_drag_pos)
return
self._update_cursor(event.globalPosition().toPoint())
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
edge = self._edge_at(event.pos())
if edge:
self._resizing = True
self._resize_edge = edge
self._resize_start_global = event.globalPosition().toPoint()
self._resize_start_geo = self.geometry()
elif self.container.geometry().contains(event.pos()):
pos_c = self.container.mapFrom(self, event.pos())
if self._container_pos_is_title_bar(pos_c):
self._win_drag_pos = (
event.globalPosition().toPoint()
- self.frameGeometry().topLeft()
)
def mouseReleaseEvent(self, event):
self._resizing = False
self._resize_edge = None
self._win_drag_pos = None
self._update_cursor(event.globalPosition().toPoint())
self._check_dock_state()
self._save_geometry() # 拖动/resize 结束后保存位置和尺寸
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()
def focusOutEvent(self, event):
if not self._pinned:
QTimer.singleShot(200, self._check_focus_lost)
def _check_focus_lost(self):
if self._pinned or not self.isVisible():
return
if self.isActiveWindow():
return
# 焦点转移到了本面板弹出的对话框/子窗口(添加分组、设置、右键菜单等)时不隐藏
active = QApplication.activeWindow()
if active is not None and active is not self:
p = active.parentWidget()
while p is not None:
if p is self:
return
p = p.parentWidget()
if QApplication.activePopupWidget() is not None:
return
if getattr(self, "_dock_state", "none") != "none":
self._temp_disable_collapse = False
if not self._is_collapsed:
self._collapse_panel()
return
self.hide_panel()
# ── 托盘 ─────────────────────────────────────────────
def _setup_tray(self):
self.tray = QSystemTrayIcon(self)
self.tray.setIcon(_make_tray_icon())
self.tray.setToolTip("桌面文件整理")
menu = QMenu()
menu.setStyleSheet(self._bottom_menu_stylesheet())
for tooltip, _icon, callback in self._get_quick_actions():
menu.addAction(tooltip).triggered.connect(callback)
menu.addSeparator()
menu.addAction("🔄 重启本程序").triggered.connect(self._restart_application)
menu.addAction("❌ 退出本程序").triggered.connect(QApplication.quit)
self.tray.setContextMenu(menu)
self._tray_menu = menu # 主题切换时同步样式
self.tray.activated.connect(self._on_tray_activated)
self.tray.show()
def _on_tray_activated(self, reason):
if reason in (
QSystemTrayIcon.ActivationReason.Trigger,
QSystemTrayIcon.ActivationReason.DoubleClick,
):
if self.isVisible():
self.minimize_to_ball()
elif hasattr(self, "_ball_ref"):
self.show_near(self._ball_ref.pos(), self._ball_ref.width())
# ── 边缘吸附与收起 ─────────────────────────────────────
def _check_dock_state(self):
if self._pinned:
self._dock_state = "none"
return
screen = QApplication.screenAt(self.geometry().center())
if not screen:
screen = QApplication.primaryScreen()
screen_geom = screen.geometry()
x, y, w, h = self.x(), self.y(), self.width(), self.height()
threshold = 20
# 检测顶部吸附
if abs(y - screen_geom.top()) <= threshold:
self._dock_state = "top"
self.move(x, screen_geom.top())
# 检测左侧吸附
elif abs(x - screen_geom.left()) <= threshold:
self._dock_state = "left"
self.move(screen_geom.left(), y)
# 检测右侧吸附
elif abs((x + w) - screen_geom.right()) <= threshold:
self._dock_state = "right"
self.move(screen_geom.right() - w, y)
else:
self._dock_state = "none"
def _on_dock_timer_timeout(self):
if not self.isVisible() or self._dock_state == "none" or self._pinned or self._win_drag_pos or self._resizing:
return
cursor_pos = QCursor.pos()
geom = self.geometry()
is_hovered = geom.contains(cursor_pos)
if not is_hovered:
screen = QApplication.screenAt(cursor_pos)
if screen:
screen_geom = screen.geometry()
tolerance = 3
if self._dock_state == "top" and cursor_pos.y() <= screen_geom.top() + tolerance:
if geom.left() <= cursor_pos.x() <= geom.right():
is_hovered = True
elif self._dock_state == "left" and cursor_pos.x() <= screen_geom.left() + tolerance:
if geom.top() <= cursor_pos.y() <= geom.bottom():
is_hovered = True
elif self._dock_state == "right" and cursor_pos.x() >= screen_geom.right() - tolerance:
if geom.top() <= cursor_pos.y() <= geom.bottom():
is_hovered = True
if is_hovered:
self._temp_disable_collapse = False
if self._is_collapsed:
self._expand_panel()
else:
if not self._is_collapsed and not getattr(self, "_temp_disable_collapse", False):
self._collapse_panel()
def _collapse_panel(self):
if self._is_collapsed:
return
self._is_collapsed = True
screen = QApplication.screenAt(self.geometry().center())
if not screen:
screen = QApplication.primaryScreen()
screen_geom = screen.geometry()
x, y, w, h = self.x(), self.y(), self.width(), self.height()
target_x, target_y = x, y
sliver = 3 # 边缘缩进后露出的像素宽度
if self._dock_state == "top":
target_y = screen_geom.top() - h + sliver
elif self._dock_state == "left":
target_x = screen_geom.left() - w + sliver
elif self._dock_state == "right":
target_x = screen_geom.right() - sliver
self._animate_to(target_x, target_y)
def _expand_panel(self):
if not self._is_collapsed:
return
self._is_collapsed = False
screen = QApplication.screenAt(self.geometry().center())
if not screen:
screen = QApplication.primaryScreen()
screen_geom = screen.geometry()
x, y, w, h = self.x(), self.y(), self.width(), self.height()
target_x, target_y = x, y
if self._dock_state == "top":
target_y = screen_geom.top()
elif self._dock_state == "left":
target_x = screen_geom.left()
elif self._dock_state == "right":
target_x = screen_geom.right() - w
self._animate_to(target_x, target_y)
def _animate_to(self, x, y):
if hasattr(self, "_dock_anim") and self._dock_anim:
self._dock_anim.stop()
self._dock_anim = QPropertyAnimation(self, b"pos")
self._dock_anim.setDuration(200)
self._dock_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
self._dock_anim.setStartValue(self.pos())
self._dock_anim.setEndValue(QPoint(x, y))
if not self._is_collapsed:
self._dock_anim.finished.connect(self.raise_)
self._dock_anim.start()
def _apply_win32_blur(self, is_dark: bool):
if sys.platform != "win32":
return
try:
import ctypes
from ctypes import windll, sizeof, byref, c_int, Structure
class ACCENT_POLICY(Structure):
_fields_ = [
("AccentState", c_int),
("AccentFlags", c_int),
("GradientColor", c_int),
("AnimationId", c_int)
]
class WINDOWCOMPOSITIONATTRIBDATA(Structure):
_fields_ = [
("Attribute", c_int),
("Data", ctypes.c_void_p),
("SizeOfData", ctypes.c_int)
]
hwnd = int(self.winId())
accent = ACCENT_POLICY()
accent.AccentState = 4 # ACCENT_ENABLE_ACRYLICBLURBEHIND
if is_dark:
# 磨砂黑色:深灰色半透明
accent.GradientColor = 0x90202020
else:
# 磨砂白色:白色半透明
accent.GradientColor = 0x90F5F5F5
accent.AccentFlags = 2
data = WINDOWCOMPOSITIONATTRIBDATA()
data.Attribute = 19 # WCA_ACCENT_POLICY
data.Data = ctypes.cast(byref(accent), ctypes.c_void_p)
data.SizeOfData = sizeof(accent)
windll.user32.SetWindowCompositionAttribute(hwnd, byref(data))
except Exception as e:
print("Failed to apply win32 composition blur:", e)
def _set_autostart(enable: bool):
try:
import winreg
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run",
0,
winreg.KEY_SET_VALUE,
)
app_name = "DesktopOrganizer"
if enable:
exe = (
# 用 __file__ 定位入口,避免从其它工作目录启动时写入错误路径
f'"{sys.executable}" "{os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), "main.py"))}"'
if not getattr(sys, "frozen", False)
else f'"{sys.executable}"'
)
winreg.SetValueEx(key, app_name, 0, winreg.REG_SZ, exe)
else:
try:
winreg.DeleteValue(key, app_name)
except FileNotFoundError:
pass
winreg.CloseKey(key)
except Exception as e:
print(f"自启设置失败: {e}")