niumasoftware/ui/dock.py

1731 lines
64 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,
QGridLayout,
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)
self._last_tooltip: str | None = None
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("")
self._last_tooltip = None
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
# 默认按中国时区(大概率与你的 IP 定位一致);后续会在天气接口返回后修正
self._tz_offset_sec: int = 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)
def showEvent(self, event):
super().showEvent(event)
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
# 用 weather 接口返回的 adcode由 IP 自动定位)推断时区偏移
# 目前你的接口数据基本为中国区(你给的示例 adcode=320706中国全时区统一为 UTC+8
try:
adcode = data.get("adcode", "")
ad_str = str(adcode).strip()
if ad_str.isdigit() and len(ad_str) == 6:
self._tz_offset_sec = 8 * 3600
else:
self._tz_offset_sec = 0
except Exception:
self._tz_offset_sec = 8 * 3600
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.utcfromtimestamp(local_now)
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),
]
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("")
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.textChanged.connect(self._on_search)
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.container.setStyleSheet(
f"""
QWidget#container {{
background: {t['panel_bg']};
border-radius: 10px;
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)
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))
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(
"border:none; background:transparent; border-radius:6px;"
)
# 刷新按钮图标跟随主题颜色
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: 10px;
}}
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 10px 10px;
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))
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_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
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()
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()
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):
"""把当前位置和尺寸写入数据库"""
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()))
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
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):
# 根据球所在屏幕来定位 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.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):
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)
self._anim.finished.connect(self.hide)
self._anim.start()
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.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._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.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._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 not self.isActiveWindow() and not self._pinned:
self.hide_panel()
# ── 托盘 ─────────────────────────────────────────────
def _setup_tray(self):
self.tray = QSystemTrayIcon(self)
self.tray.setIcon(_make_tray_icon())
self.tray.setToolTip("桌面文件整理")
menu = QMenu()
menu.setStyleSheet(
"""
QMenu { background:#2b2b2b; color:#eee; border:1px solid #555;
padding:4px; border-radius:6px; }
QMenu::item { padding:6px 20px; border-radius:4px; }
QMenu::item:selected { background:#3a3a3a; }
QMenu::separator { height:1px; background:#444; margin:4px 8px; }
"""
)
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.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 _toggle_autostart(self):
self._autostart_enabled = not self._autostart_enabled
_set_autostart(self._autostart_enabled)
self._autostart_act.setText(
f"🚀 开机自启: {'' if self._autostart_enabled else ''}"
)
class SettingsPopup(QWidget):
"""设置弹出面板:透明度调节等"""
def __init__(self, panel: "PanelWindow"):
super().__init__(panel, Qt.WindowType.Popup)
self._panel = panel
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setWindowFlags(Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint)
self._build()
def _build(self):
from PyQt6.QtWidgets import QSlider, QLabel, QVBoxLayout, QHBoxLayout
t = theme.current()
is_dark = theme.name() == "dark"
self.setStyleSheet(
f"""
QWidget {{
background: {t['panel_bg']};
border: 1px solid {t['panel_border']};
border-radius: 8px;
color: {t['search_color']};
}}
QSlider::groove:horizontal {{
height: 4px;
background: {t['scrollbar']};
border-radius: 2px;
}}
QSlider::handle:horizontal {{
width: 14px; height: 14px;
margin: -5px 0;
background: #4a9eff;
border-radius: 7px;
}}
QSlider::sub-page:horizontal {{
background: #4a9eff;
border-radius: 2px;
}}
"""
)
layout = QVBoxLayout(self)
layout.setContentsMargins(14, 12, 14, 12)
layout.setSpacing(10)
# 透明度
row = QHBoxLayout()
row.setSpacing(10)
lbl = QLabel("透明度")
lbl.setStyleSheet(
f"color:{t['search_color']}; font-size:12px; background:transparent; border:none;"
)
lbl.setFixedWidth(42)
self._opacity_slider = QSlider(Qt.Orientation.Horizontal)
self._opacity_slider.setRange(30, 100)
saved_opacity = int(database.get_setting("panel_opacity", "100"))
self._opacity_slider.setValue(saved_opacity)
self._opacity_slider.setFixedWidth(140)
self._opacity_slider.valueChanged.connect(self._on_opacity)
self._opacity_val = QLabel(f"{self._opacity_slider.value()}%")
self._opacity_val.setStyleSheet(
f"color:{t['search_color']}; font-size:11px; background:transparent; border:none;"
)
self._opacity_val.setFixedWidth(34)
row.addWidget(lbl)
row.addWidget(self._opacity_slider)
row.addWidget(self._opacity_val)
layout.addLayout(row)
self.adjustSize()
def _on_opacity(self, val: int):
self._panel.setWindowOpacity(val / 100)
self._opacity_val.setText(f"{val}%")
database.set_setting("panel_opacity", str(val))
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 = (
f'"{sys.executable}" "{os.path.abspath("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}")