first commit
47
CleanDesktopOrganizer.spec
Normal file
@ -0,0 +1,47 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
|
||||
datas = [('assets', 'assets'), ('logo.png', '.')]
|
||||
binaries = []
|
||||
hiddenimports = ['qtawesome']
|
||||
tmp_ret = collect_all('qtawesome')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('PyQt6')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['main.py'],
|
||||
pathex=[],
|
||||
binaries=binaries,
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='CleanDesktopOrganizer',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=['logo.png'],
|
||||
)
|
||||
BIN
__pycache__/main.cpython-313.pyc
Normal file
BIN
__pycache__/shortcut_target.cpython-313.pyc
Normal file
BIN
assets/imgs/donate/wx.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
assets/imgs/donate/zfb.jpg
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
assets/imgs/weather/中雨.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
assets/imgs/weather/多云.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
assets/imgs/weather/大雨.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
assets/imgs/weather/小雨.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
assets/imgs/weather/晴.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
assets/imgs/weather/暴雨.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
assets/imgs/weather/阴.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
assets/imgs/weather/雷阵雨.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
assets/imgs/weather/霾.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
1
db/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# db package
|
||||
BIN
db/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
db/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
db/__pycache__/database.cpython-313.pyc
Normal file
230
db/database.py
Normal file
@ -0,0 +1,230 @@
|
||||
import sqlite3
|
||||
import os
|
||||
import shutil
|
||||
|
||||
# 数据库放到 Windows 常见的 APPDATA 目录(避免写在项目目录)
|
||||
APP_NAME = "CleanDesktopOrganizer"
|
||||
|
||||
_PROJECT_DB_PATH = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)), "data.db"
|
||||
)
|
||||
|
||||
_BASE_DIR = os.environ.get("APPDATA") or os.environ.get("LOCALAPPDATA") or ""
|
||||
DB_DIR = os.path.join(_BASE_DIR, APP_NAME) if _BASE_DIR else None
|
||||
DB_PATH = os.path.join(DB_DIR, "data.db") if DB_DIR else _PROJECT_DB_PATH
|
||||
|
||||
|
||||
def _ensure_db_dir():
|
||||
if DB_DIR:
|
||||
os.makedirs(DB_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def _migrate_old_db_if_needed():
|
||||
"""
|
||||
首次升级:把旧版项目目录下的 data.db 自动拷贝到 APPDATA,
|
||||
避免你之前分组/设置丢失。
|
||||
"""
|
||||
try:
|
||||
if os.path.isfile(_PROJECT_DB_PATH) and not os.path.isfile(DB_PATH):
|
||||
_ensure_db_dir()
|
||||
shutil.copy2(_PROJECT_DB_PATH, DB_PATH)
|
||||
except Exception:
|
||||
# 迁移失败不影响程序运行(会在新目录重新创建库)
|
||||
pass
|
||||
|
||||
|
||||
def get_conn():
|
||||
_ensure_db_dir()
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def init_db():
|
||||
_migrate_old_db_if_needed()
|
||||
conn = get_conn()
|
||||
c = conn.cursor()
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER DEFAULT 0,
|
||||
folder_path TEXT DEFAULT ''
|
||||
)
|
||||
""")
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
icon_path TEXT,
|
||||
position INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
# 迁移:旧库没有 folder_path 字段时自动添加
|
||||
try:
|
||||
c.execute("ALTER TABLE groups ADD COLUMN folder_path TEXT DEFAULT ''")
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass # 字段已存在则忽略
|
||||
|
||||
# 默认分组
|
||||
c.execute("SELECT COUNT(*) FROM groups")
|
||||
if c.fetchone()[0] == 0:
|
||||
c.execute("INSERT INTO groups (name, position) VALUES ('常用程序', 0)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
_migrate_item_shortcut_paths_to_targets()
|
||||
|
||||
|
||||
def _migrate_item_shortcut_paths_to_targets():
|
||||
"""将旧数据中仍保存为 .lnk 的路径改存为快捷方式目标(若可解析)。仅执行一次。"""
|
||||
conn = get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT value FROM settings WHERE key='items_lnk_targets_migrated'"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row and row["value"] == "1":
|
||||
return
|
||||
|
||||
from shortcut_target import path_for_storage
|
||||
|
||||
conn = get_conn()
|
||||
rows = conn.execute("SELECT id, path FROM items").fetchall()
|
||||
for r in rows:
|
||||
iid, p = r["id"], (r["path"] or "")
|
||||
pl = p.lower()
|
||||
if not pl.endswith(".lnk"):
|
||||
continue
|
||||
try:
|
||||
if not os.path.isfile(p):
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
new_p = path_for_storage(p)
|
||||
if new_p != p:
|
||||
conn.execute("UPDATE items SET path=? WHERE id=?", (new_p, iid))
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?,?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
("items_lnk_targets_migrated", "1"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── Settings ─────────────────────────────────────────────
|
||||
def get_setting(key: str, default: str = "") -> str:
|
||||
conn = get_conn()
|
||||
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
||||
conn.close()
|
||||
return row["value"] if row else default
|
||||
|
||||
|
||||
def set_setting(key: str, value: str):
|
||||
conn = get_conn()
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?,?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
(key, value)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── Groups ──────────────────────────────────────────────
|
||||
def get_groups():
|
||||
conn = get_conn()
|
||||
rows = conn.execute("SELECT * FROM groups ORDER BY position").fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def add_group(name, folder_path=""):
|
||||
conn = get_conn()
|
||||
conn.execute("INSERT INTO groups (name, folder_path) VALUES (?,?)", (name, folder_path))
|
||||
conn.commit()
|
||||
gid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
conn.close()
|
||||
return gid
|
||||
|
||||
|
||||
def rename_group(gid, name):
|
||||
conn = get_conn()
|
||||
conn.execute("UPDATE groups SET name=? WHERE id=?", (name, gid))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_group(gid):
|
||||
conn = get_conn()
|
||||
conn.execute("DELETE FROM groups WHERE id=?", (gid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def reorder_groups(id_list):
|
||||
conn = get_conn()
|
||||
for pos, gid in enumerate(id_list):
|
||||
conn.execute("UPDATE groups SET position=? WHERE id=?", (pos, gid))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── Items ────────────────────────────────────────────────
|
||||
def get_items(group_id):
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM items WHERE group_id=? ORDER BY position", (group_id,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def add_item(group_id, name, path, icon_path=None):
|
||||
from shortcut_target import path_for_storage
|
||||
|
||||
path = path_for_storage(path)
|
||||
conn = get_conn()
|
||||
conn.execute(
|
||||
"INSERT INTO items (group_id, name, path, icon_path) VALUES (?,?,?,?)",
|
||||
(group_id, name, path, icon_path),
|
||||
)
|
||||
conn.commit()
|
||||
iid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
conn.close()
|
||||
return iid
|
||||
|
||||
|
||||
def delete_item(item_id):
|
||||
conn = get_conn()
|
||||
conn.execute("DELETE FROM items WHERE id=?", (item_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def move_item(item_id, new_group_id, new_position):
|
||||
conn = get_conn()
|
||||
conn.execute(
|
||||
"UPDATE items SET group_id=?, position=? WHERE id=?",
|
||||
(new_group_id, new_position, item_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def reorder_items(group_id, id_list):
|
||||
conn = get_conn()
|
||||
for pos, iid in enumerate(id_list):
|
||||
conn.execute("UPDATE items SET position=? WHERE id=?", (pos, iid))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
106
main.py
Normal file
@ -0,0 +1,106 @@
|
||||
import sys
|
||||
import time
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
from db.database import init_db
|
||||
from ui.dock import PanelWindow, _set_autostart
|
||||
from ui.ball import FloatBall, BALL_SIZE
|
||||
import ui.theme as theme
|
||||
from db import database
|
||||
|
||||
|
||||
def _wake_existing_or_exit() -> bool:
|
||||
"""
|
||||
Windows 单实例:
|
||||
- 若已有进程在运行:唤醒已有窗口并返回 False(当前进程退出)
|
||||
- 若无已有进程:返回 True(继续正常启动)
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return True
|
||||
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
|
||||
mutex_name = r"Global\CleanDesktopOrganizerSingleton"
|
||||
title = "牛马软件柜"
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
user32 = ctypes.windll.user32
|
||||
|
||||
ERROR_ALREADY_EXISTS = 183
|
||||
h_mutex = kernel32.CreateMutexW(None, False, mutex_name)
|
||||
if not h_mutex:
|
||||
return True
|
||||
|
||||
last_err = kernel32.GetLastError()
|
||||
if last_err == ERROR_ALREADY_EXISTS:
|
||||
# 轮询一小段时间,避免首次窗口尚未创建
|
||||
hwnd = None
|
||||
for _ in range(8): # ~2.4s
|
||||
hwnd = user32.FindWindowW(None, title)
|
||||
if hwnd:
|
||||
break
|
||||
time.sleep(0.3)
|
||||
|
||||
if hwnd:
|
||||
# SW_SHOW = 5
|
||||
user32.ShowWindow(hwnd, 5)
|
||||
user32.SetForegroundWindow(hwnd)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
# 必须在创建 QApplication 之前:不按各显示器缩放,逻辑像素固定(跨分辨率/跨屏拖动宽高保持一致)
|
||||
if not _wake_existing_or_exit():
|
||||
return
|
||||
QApplication.setAttribute(Qt.ApplicationAttribute.AA_Use96Dpi, True)
|
||||
# 这个策略同样要求在创建 QApplication 前设置
|
||||
try:
|
||||
QApplication.setHighDpiScaleFactorRoundingPolicy(
|
||||
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
app = QApplication(sys.argv)
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
init_db()
|
||||
theme.load() # 从数据库读取上次主题
|
||||
_set_autostart(True)
|
||||
|
||||
panel = PanelWindow()
|
||||
ball = FloatBall()
|
||||
|
||||
panel._ball_ref = ball
|
||||
|
||||
ball.clicked.connect(lambda: panel.show_near(ball.pos(), BALL_SIZE))
|
||||
ball.right_clicked.connect(lambda pos: panel.tray.contextMenu().exec(pos))
|
||||
|
||||
# 判断是否有保存的位置:有则直接在原位显示,没有则用悬浮球旁边
|
||||
has_saved = bool(database.get_setting("panel_x", ""))
|
||||
if has_saved:
|
||||
# _restore_geometry 已在 PanelWindow.__init__ 里执行,直接 show
|
||||
panel.setWindowOpacity(0)
|
||||
panel.show()
|
||||
panel.raise_()
|
||||
if hasattr(panel, "_ball_ref"):
|
||||
panel._ball_ref.hide()
|
||||
# 淡入
|
||||
from PyQt6.QtCore import QPropertyAnimation
|
||||
anim = QPropertyAnimation(panel, b"windowOpacity")
|
||||
anim.setDuration(180)
|
||||
anim.setStartValue(0.0)
|
||||
anim.setEndValue(1.0)
|
||||
anim.start()
|
||||
panel._anim = anim
|
||||
else:
|
||||
ball._place_default()
|
||||
panel.show_near(ball.pos(), BALL_SIZE)
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
requirements.txt
Normal file
@ -0,0 +1 @@
|
||||
PyQt6>=6.4.0
|
||||
123
shortcut_target.py
Normal file
@ -0,0 +1,123 @@
|
||||
"""将用户选中的 .lnk / .url 解析为实际要存储的路径(目标程序或 URL)。"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def _looks_like_shell_lnk(path: str) -> bool:
|
||||
"""Windows Shell Link 文件头为 4C 00 00 00(扩展名异常时仍可识别)。"""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return f.read(4) == b"L\x00\x00\x00"
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _read_internet_shortcut_url(path: str) -> str | None:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
for line in f:
|
||||
s = line.strip()
|
||||
if s[:4].upper() == "URL=":
|
||||
return s[4:].strip()
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_windows_lnk(lnk_path: str) -> str | None:
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
env = os.environ.copy()
|
||||
env["LNK_B64"] = base64.b64encode(os.path.normpath(lnk_path).encode("utf-8")).decode(
|
||||
"ascii"
|
||||
)
|
||||
ps = (
|
||||
"$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($env:LNK_B64));"
|
||||
"$sc=(New-Object -ComObject WScript.Shell).CreateShortcut($p);"
|
||||
"@{ t=[string]$sc.TargetPath; a=[string]$sc.Arguments }|ConvertTo-Json -Compress"
|
||||
)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-Sta",
|
||||
"-Command",
|
||||
ps,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=env,
|
||||
timeout=15,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
raw = (r.stdout or "").strip().lstrip("\ufeff")
|
||||
if not raw:
|
||||
return None
|
||||
data = json.loads(raw)
|
||||
target = (data.get("t") or "").strip()
|
||||
args = (data.get("a") or "").strip()
|
||||
if not target:
|
||||
return None
|
||||
low = target.lower()
|
||||
if low.endswith("cmd.exe") or low.endswith("\\cmd.exe"):
|
||||
for m in re.finditer(
|
||||
r"([a-zA-Z]:[^\"'\s]+\.(?:bat|cmd))|\x22([a-zA-Z]:[^\"]+\.(?:bat|cmd))\x22",
|
||||
args,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
cand = (m.group(1) or m.group(2) or "").strip('"')
|
||||
if cand and os.path.isfile(cand):
|
||||
return os.path.normpath(cand)
|
||||
if os.path.isfile(target) or low.startswith("\\\\"):
|
||||
return os.path.normpath(target)
|
||||
return os.path.normpath(target)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def path_for_storage(local_path: str) -> str:
|
||||
"""
|
||||
拖拽/选择文件时写入数据库的 path:.lnk/.url 解析为目标,其它保持原路径。
|
||||
解析失败时退回本地路径。
|
||||
"""
|
||||
if not local_path:
|
||||
return local_path
|
||||
local_path = os.path.normpath(local_path.strip())
|
||||
|
||||
ext = os.path.splitext(local_path)[1].lower()
|
||||
if ext == ".url":
|
||||
try:
|
||||
if os.path.isfile(local_path):
|
||||
url = _read_internet_shortcut_url(local_path)
|
||||
if url:
|
||||
return url
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return local_path
|
||||
|
||||
try:
|
||||
is_file = os.path.isfile(local_path)
|
||||
except (OSError, ValueError):
|
||||
is_file = False
|
||||
|
||||
is_lnk = ext in (".lnk", ".ink") or (
|
||||
is_file and _looks_like_shell_lnk(local_path)
|
||||
)
|
||||
if is_lnk and is_file:
|
||||
resolved = _resolve_windows_lnk(local_path)
|
||||
if resolved:
|
||||
return resolved
|
||||
return local_path
|
||||
|
||||
|
||||
def item_name_from_sources(local_path: str, store_path: str) -> str:
|
||||
"""列表显示名:仍用快捷方式/所选文件的文件名(无扩展名),与原来行为一致。"""
|
||||
base = os.path.splitext(os.path.basename(local_path))[0]
|
||||
return base or os.path.basename(store_path)
|
||||
1
ui/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# ui package
|
||||
BIN
ui/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
ui/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
ui/__pycache__/ball.cpython-313.pyc
Normal file
BIN
ui/__pycache__/dialog_style.cpython-313.pyc
Normal file
BIN
ui/__pycache__/dock.cpython-313.pyc
Normal file
BIN
ui/__pycache__/flow_layout.cpython-313.pyc
Normal file
BIN
ui/__pycache__/group.cpython-313.pyc
Normal file
BIN
ui/__pycache__/item.cpython-313.pyc
Normal file
BIN
ui/__pycache__/settings_window.cpython-313.pyc
Normal file
BIN
ui/__pycache__/theme.cpython-313.pyc
Normal file
206
ui/ball.py
Normal file
@ -0,0 +1,206 @@
|
||||
import os
|
||||
from PyQt6.QtWidgets import QWidget, QApplication
|
||||
from PyQt6.QtCore import Qt, QPoint, QTimer, pyqtSignal, QPropertyAnimation, QEasingCurve, QRectF
|
||||
from PyQt6.QtGui import (QPainter, QPixmap, QBrush, QColor, QPen,
|
||||
QPainterPath, QRadialGradient, QIcon)
|
||||
|
||||
BALL_SIZE = 60
|
||||
LOGO_SIZE = 34
|
||||
LOGO_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logo.png")
|
||||
DRAG_THRESHOLD = 6
|
||||
|
||||
|
||||
class FloatBall(QWidget):
|
||||
clicked = pyqtSignal()
|
||||
right_clicked = pyqtSignal(QPoint)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowFlags(
|
||||
Qt.WindowType.FramelessWindowHint |
|
||||
Qt.WindowType.WindowStaysOnTopHint |
|
||||
Qt.WindowType.Tool
|
||||
)
|
||||
if os.path.exists(LOGO_PATH):
|
||||
self.setWindowIcon(QIcon(LOGO_PATH))
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
|
||||
# 关键:让整个窗口区域都透明,不显示系统边框
|
||||
self.setFixedSize(BALL_SIZE + 20, BALL_SIZE + 20) # 留出光晕空间
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
self._logo = QPixmap(LOGO_PATH) if os.path.exists(LOGO_PATH) else QPixmap()
|
||||
self._drag_start = QPoint()
|
||||
self._dragging = False
|
||||
self._hovered = False
|
||||
self._glow = 0.0 # 0.0~1.0 光晕强度
|
||||
|
||||
# 光晕动画
|
||||
self._glow_anim = QPropertyAnimation(self, b"_glow_prop")
|
||||
self._glow_anim.setDuration(300)
|
||||
self._glow_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
|
||||
|
||||
# 闲置淡出
|
||||
self._idle_timer = QTimer(self)
|
||||
self._idle_timer.setSingleShot(True)
|
||||
self._idle_timer.setInterval(3000)
|
||||
self._idle_timer.timeout.connect(self._fade_out)
|
||||
|
||||
self._place_default()
|
||||
self._idle_timer.start()
|
||||
|
||||
# Qt property 用于动画
|
||||
def _get_glow(self): return self._glow
|
||||
def _set_glow(self, v): self._glow = v; self.update()
|
||||
from PyQt6.QtCore import pyqtProperty
|
||||
_glow_prop = pyqtProperty(float, _get_glow, _set_glow)
|
||||
|
||||
def _place_default(self):
|
||||
screen = QApplication.primaryScreen().availableGeometry()
|
||||
pad = (self.width() - BALL_SIZE) // 2
|
||||
self.move(screen.right() - self.width() - 20 + pad,
|
||||
screen.top() + screen.height() // 2 - self.height() // 2)
|
||||
|
||||
# ── 绘制 ────────────────────────────────────────────
|
||||
def paintEvent(self, event):
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
pad = (self.width() - BALL_SIZE) // 2 # 光晕留白
|
||||
cx = self.width() / 2
|
||||
cy = self.height() / 2
|
||||
r = BALL_SIZE / 2 - 1
|
||||
|
||||
# 1. 外部光晕(hover 时)
|
||||
if self._glow > 0:
|
||||
glow_r = r + 10 * self._glow
|
||||
grad = QRadialGradient(cx, cy, glow_r)
|
||||
grad.setColorAt(0, QColor(255, 255, 255, int(80 * self._glow)))
|
||||
grad.setColorAt(0.5, QColor(255, 255, 255, int(30 * self._glow)))
|
||||
grad.setColorAt(1, QColor(255, 255, 255, 0))
|
||||
p.setBrush(QBrush(grad))
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.drawEllipse(QRectF(cx - glow_r, cy - glow_r, glow_r * 2, glow_r * 2))
|
||||
|
||||
# 2. 圆形裁剪
|
||||
clip = QPainterPath()
|
||||
clip.addEllipse(QRectF(cx - r, cy - r, r * 2, r * 2))
|
||||
p.setClipPath(clip)
|
||||
|
||||
# 3. 白色半透明毛玻璃底
|
||||
p.setBrush(QBrush(QColor(255, 255, 255, 210)))
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.drawEllipse(QRectF(cx - r, cy - r, r * 2, r * 2))
|
||||
|
||||
# 4. 顶部高光
|
||||
hi = QRadialGradient(cx, cy - r * 0.3, r * 0.9)
|
||||
hi.setColorAt(0, QColor(255, 255, 255, 140))
|
||||
hi.setColorAt(1, QColor(255, 255, 255, 0))
|
||||
p.setBrush(QBrush(hi))
|
||||
p.drawEllipse(QRectF(cx - r, cy - r, r * 2, r * 2))
|
||||
|
||||
# 5. logo 居中
|
||||
if not self._logo.isNull():
|
||||
scaled = self._logo.scaled(
|
||||
LOGO_SIZE, LOGO_SIZE,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
lx = int(cx - scaled.width() / 2)
|
||||
ly = int(cy - scaled.height() / 2)
|
||||
p.drawPixmap(lx, ly, scaled)
|
||||
|
||||
# 6. 边框(取消裁剪后画)
|
||||
p.setClipping(False)
|
||||
p.setBrush(Qt.BrushStyle.NoBrush)
|
||||
p.setPen(QPen(QColor(200, 200, 200, 120), 1.5))
|
||||
p.drawEllipse(QRectF(cx - r, cy - r, r * 2, r * 2))
|
||||
|
||||
# 7. hover 时内圈白色光晕边
|
||||
if self._glow > 0:
|
||||
p.setPen(QPen(QColor(255, 255, 255, int(160 * self._glow)), 2))
|
||||
p.drawEllipse(QRectF(cx - r + 1, cy - r + 1, (r - 1) * 2, (r - 1) * 2))
|
||||
|
||||
p.end()
|
||||
|
||||
# ── 鼠标事件 ────────────────────────────────────────
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self._drag_start = event.globalPosition().toPoint()
|
||||
self._dragging = False
|
||||
self.setWindowOpacity(1.0)
|
||||
self._idle_timer.stop()
|
||||
elif event.button() == Qt.MouseButton.RightButton:
|
||||
self.right_clicked.emit(event.globalPosition().toPoint())
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if not (event.buttons() & Qt.MouseButton.LeftButton):
|
||||
return
|
||||
delta = event.globalPosition().toPoint() - self._drag_start
|
||||
if not self._dragging and delta.manhattanLength() > DRAG_THRESHOLD:
|
||||
self._dragging = True
|
||||
if self._dragging:
|
||||
new_pos = self.pos() + delta
|
||||
self._drag_start = event.globalPosition().toPoint()
|
||||
screen = QApplication.primaryScreen().availableGeometry()
|
||||
new_pos.setX(max(screen.left(), min(new_pos.x(), screen.right() - self.width())))
|
||||
new_pos.setY(max(screen.top(), min(new_pos.y(), screen.bottom() - self.height())))
|
||||
self.move(new_pos)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
if not self._dragging:
|
||||
self.clicked.emit()
|
||||
else:
|
||||
self._snap_to_edge()
|
||||
self._idle_timer.start()
|
||||
|
||||
def enterEvent(self, event):
|
||||
self._hovered = True
|
||||
self.setWindowOpacity(1.0)
|
||||
self._idle_timer.stop()
|
||||
self._glow_anim.stop()
|
||||
self._glow_anim.setStartValue(self._glow)
|
||||
self._glow_anim.setEndValue(1.0)
|
||||
self._glow_anim.start()
|
||||
|
||||
def leaveEvent(self, event):
|
||||
self._hovered = False
|
||||
self._glow_anim.stop()
|
||||
self._glow_anim.setStartValue(self._glow)
|
||||
self._glow_anim.setEndValue(0.0)
|
||||
self._glow_anim.start()
|
||||
self._idle_timer.start()
|
||||
|
||||
# ── 吸附边缘 ─────────────────────────────────────────
|
||||
def _snap_to_edge(self):
|
||||
screen = QApplication.primaryScreen().availableGeometry()
|
||||
cx = self.x() + self.width() // 2
|
||||
cy = self.y() + self.height() // 2
|
||||
dists = {
|
||||
"l": cx - screen.left(),
|
||||
"r": screen.right() - cx,
|
||||
"t": cy - screen.top(),
|
||||
"b": screen.bottom() - cy,
|
||||
}
|
||||
side = min(dists, key=dists.get)
|
||||
ends = {
|
||||
"l": QPoint(screen.left(), self.y()),
|
||||
"r": QPoint(screen.right() - self.width(), self.y()),
|
||||
"t": QPoint(self.x(), screen.top()),
|
||||
"b": QPoint(self.x(), screen.bottom() - self.height()),
|
||||
}
|
||||
anim = QPropertyAnimation(self, b"pos")
|
||||
anim.setDuration(200)
|
||||
anim.setEasingCurve(QEasingCurve.Type.OutCubic)
|
||||
anim.setStartValue(self.pos())
|
||||
anim.setEndValue(ends[side])
|
||||
self._snap_anim = anim
|
||||
anim.start()
|
||||
|
||||
def _fade_out(self):
|
||||
self._anim_fade = QPropertyAnimation(self, b"windowOpacity")
|
||||
self._anim_fade.setDuration(600)
|
||||
self._anim_fade.setStartValue(1.0)
|
||||
self._anim_fade.setEndValue(0.3)
|
||||
self._anim_fade.start()
|
||||
222
ui/dialog_style.py
Normal file
@ -0,0 +1,222 @@
|
||||
"""标准对话框(QMessageBox / QInputDialog / QFileDialog)与当前主题一致。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QFileDialog,
|
||||
QInputDialog,
|
||||
QMessageBox,
|
||||
QWidget,
|
||||
QDialog,
|
||||
)
|
||||
|
||||
import ui.theme as theme
|
||||
|
||||
|
||||
def stylesheet() -> str:
|
||||
t = theme.current()
|
||||
bg = t["menu_bg"]
|
||||
fg = t["menu_color"]
|
||||
border = t["menu_border"]
|
||||
hover = t["menu_selected"]
|
||||
inp = t["search_bg"]
|
||||
inp_b = t["search_border"]
|
||||
focus = t["search_focus"]
|
||||
return f"""
|
||||
QMessageBox {{
|
||||
background-color: {bg};
|
||||
color: {fg};
|
||||
}}
|
||||
QMessageBox QLabel {{
|
||||
color: {fg};
|
||||
background: transparent;
|
||||
min-width: 160px;
|
||||
}}
|
||||
QMessageBox QPushButton {{
|
||||
min-width: 64px;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid {border};
|
||||
border-radius: 5px;
|
||||
background: {inp};
|
||||
color: {fg};
|
||||
}}
|
||||
QMessageBox QPushButton:hover {{
|
||||
background: {hover};
|
||||
}}
|
||||
QMessageBox QPushButton:default {{
|
||||
border-color: {focus};
|
||||
}}
|
||||
QInputDialog {{
|
||||
background-color: {bg};
|
||||
color: {fg};
|
||||
}}
|
||||
QInputDialog QLabel {{
|
||||
color: {fg};
|
||||
background: transparent;
|
||||
}}
|
||||
QInputDialog QLineEdit {{
|
||||
background: {inp};
|
||||
border: 1px solid {inp_b};
|
||||
border-radius: 5px;
|
||||
padding: 6px 8px;
|
||||
color: {fg};
|
||||
min-width: 260px;
|
||||
}}
|
||||
QInputDialog QLineEdit:focus {{
|
||||
border-color: {focus};
|
||||
}}
|
||||
QInputDialog QPushButton {{
|
||||
min-width: 72px;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid {border};
|
||||
border-radius: 5px;
|
||||
background: {inp};
|
||||
color: {fg};
|
||||
}}
|
||||
QInputDialog QPushButton:hover {{
|
||||
background: {hover};
|
||||
}}
|
||||
QFileDialog {{
|
||||
background-color: {bg};
|
||||
color: {fg};
|
||||
}}
|
||||
QFileDialog QLabel {{
|
||||
color: {fg};
|
||||
background: transparent;
|
||||
}}
|
||||
QFileDialog QLineEdit, QFileDialog QComboBox {{
|
||||
background: {inp};
|
||||
border: 1px solid {inp_b};
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
color: {fg};
|
||||
}}
|
||||
QFileDialog QLineEdit:focus, QFileDialog QComboBox:focus {{
|
||||
border-color: {focus};
|
||||
}}
|
||||
QFileDialog QTreeView, QFileDialog QListView, QFileDialog QTableView {{
|
||||
background: {inp};
|
||||
border: 1px solid {inp_b};
|
||||
border-radius: 4px;
|
||||
color: {fg};
|
||||
outline: none;
|
||||
}}
|
||||
QFileDialog QTreeView::item:selected, QFileDialog QListView::item:selected {{
|
||||
background: {hover};
|
||||
}}
|
||||
QFileDialog QPushButton {{
|
||||
min-width: 72px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid {border};
|
||||
border-radius: 5px;
|
||||
background: {inp};
|
||||
color: {fg};
|
||||
}}
|
||||
QFileDialog QPushButton:hover {{
|
||||
background: {hover};
|
||||
}}
|
||||
QFileDialog QComboBox QAbstractItemView {{
|
||||
background: {bg};
|
||||
color: {fg};
|
||||
border: 1px solid {border};
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def _apply(w: QWidget | None) -> None:
|
||||
if w is not None:
|
||||
w.setStyleSheet(stylesheet())
|
||||
|
||||
|
||||
def question(
|
||||
parent: QWidget | None,
|
||||
title: str,
|
||||
text: str,
|
||||
*,
|
||||
buttons: QMessageBox.StandardButton = (
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
),
|
||||
default_button: QMessageBox.StandardButton | None = None,
|
||||
icon: QMessageBox.Icon = QMessageBox.Icon.Question,
|
||||
) -> QMessageBox.StandardButton:
|
||||
msg = QMessageBox(parent)
|
||||
msg.setWindowTitle(title)
|
||||
msg.setText(text)
|
||||
msg.setIcon(icon)
|
||||
msg.setStandardButtons(buttons)
|
||||
if default_button is not None:
|
||||
msg.setDefaultButton(default_button)
|
||||
_apply(msg)
|
||||
# 防止对话框因为文本长度/默认最小宽度变得过宽
|
||||
msg.setFixedWidth(320)
|
||||
return QMessageBox.StandardButton(msg.exec())
|
||||
|
||||
|
||||
def warning(parent: QWidget | None, title: str, text: str) -> None:
|
||||
msg = QMessageBox(parent)
|
||||
msg.setWindowTitle(title)
|
||||
msg.setText(text)
|
||||
msg.setIcon(QMessageBox.Icon.Warning)
|
||||
msg.setStandardButtons(QMessageBox.StandardButton.Ok)
|
||||
_apply(msg)
|
||||
msg.setFixedWidth(320)
|
||||
msg.exec()
|
||||
|
||||
|
||||
def information(parent: QWidget | None, title: str, text: str) -> None:
|
||||
msg = QMessageBox(parent)
|
||||
msg.setWindowTitle(title)
|
||||
msg.setText(text)
|
||||
msg.setIcon(QMessageBox.Icon.Information)
|
||||
msg.setStandardButtons(QMessageBox.StandardButton.Ok)
|
||||
_apply(msg)
|
||||
msg.setFixedWidth(320)
|
||||
msg.exec()
|
||||
|
||||
|
||||
def get_text(
|
||||
parent: QWidget | None,
|
||||
title: str,
|
||||
label: str,
|
||||
text: str = "",
|
||||
) -> tuple[str, bool]:
|
||||
d = QInputDialog(parent)
|
||||
d.setWindowTitle(title)
|
||||
d.setLabelText(label)
|
||||
d.setTextValue(text)
|
||||
_apply(d)
|
||||
ok = d.exec() == QDialog.DialogCode.Accepted
|
||||
return d.textValue(), ok
|
||||
|
||||
|
||||
def get_open_file_name(
|
||||
parent: QWidget | None,
|
||||
caption: str,
|
||||
directory: str,
|
||||
filter_str: str,
|
||||
) -> tuple[str, str]:
|
||||
fd = QFileDialog(parent, caption, directory, filter_str)
|
||||
fd.setOption(QFileDialog.Option.DontUseNativeDialog, True)
|
||||
fd.setFileMode(QFileDialog.FileMode.ExistingFile)
|
||||
_apply(fd)
|
||||
if fd.exec() == QDialog.DialogCode.Accepted:
|
||||
files = fd.selectedFiles()
|
||||
if files:
|
||||
return files[0], fd.selectedNameFilter()
|
||||
return "", ""
|
||||
|
||||
|
||||
def get_existing_directory(
|
||||
parent: QWidget | None,
|
||||
caption: str,
|
||||
directory: str = "",
|
||||
) -> str:
|
||||
fd = QFileDialog(parent, caption, directory)
|
||||
fd.setOption(QFileDialog.Option.DontUseNativeDialog, True)
|
||||
fd.setFileMode(QFileDialog.FileMode.Directory)
|
||||
fd.setOption(QFileDialog.Option.ShowDirsOnly, True)
|
||||
_apply(fd)
|
||||
if fd.exec() == QDialog.DialogCode.Accepted:
|
||||
files = fd.selectedFiles()
|
||||
if files:
|
||||
return files[0]
|
||||
return ""
|
||||
1365
ui/dock.py
Normal file
191
ui/flow_layout.py
Normal file
@ -0,0 +1,191 @@
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
from PyQt6.QtCore import QPoint, QTimer, Qt, QRect, QSize
|
||||
from PyQt6.QtGui import QPainter, QColor, QPen
|
||||
|
||||
ITEM_W = 72
|
||||
ITEM_H = 80
|
||||
GAP = 4
|
||||
|
||||
|
||||
RUBBER_MIN = 4 # 小于此尺寸的框选视为误触
|
||||
|
||||
|
||||
class FlowContainer(QWidget):
|
||||
"""手动绝对定位的流式容器,完全绕开 QLayout。"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._widgets: list[QWidget] = []
|
||||
self._pending = False
|
||||
self._drag_highlight = False
|
||||
self.setMouseTracking(True)
|
||||
self._rubber_active = False
|
||||
self._rubber_origin = QPoint()
|
||||
self._rubber_rect: QRect | None = None
|
||||
|
||||
def add_widget(self, w: QWidget):
|
||||
w.setParent(self)
|
||||
self._widgets.append(w)
|
||||
if self.width() > 0:
|
||||
self._relayout()
|
||||
else:
|
||||
self._schedule_relayout()
|
||||
|
||||
def clear_widgets(self):
|
||||
for w in self._widgets:
|
||||
w.hide()
|
||||
w.setParent(None)
|
||||
self._widgets.clear()
|
||||
self.setFixedHeight(60)
|
||||
|
||||
def get_widgets(self) -> list:
|
||||
return list(self._widgets)
|
||||
|
||||
def set_drag_highlight(self, active: bool):
|
||||
if self._drag_highlight != active:
|
||||
self._drag_highlight = active
|
||||
self.update()
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
self._schedule_relayout()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
self._relayout()
|
||||
super().resizeEvent(event)
|
||||
|
||||
def _schedule_relayout(self):
|
||||
if not self._pending:
|
||||
self._pending = True
|
||||
QTimer.singleShot(0, self._delayed_relayout)
|
||||
|
||||
def _delayed_relayout(self):
|
||||
self._pending = False
|
||||
self._relayout()
|
||||
|
||||
def _relayout(self):
|
||||
avail_w = self.width()
|
||||
if avail_w <= 0:
|
||||
p = self.parent()
|
||||
while p:
|
||||
if p.width() > 0:
|
||||
avail_w = p.width() - 16
|
||||
break
|
||||
p = p.parent()
|
||||
if avail_w <= 0:
|
||||
self._schedule_relayout()
|
||||
return
|
||||
|
||||
x, y = GAP, GAP
|
||||
row_h = 0
|
||||
|
||||
for w in self._widgets:
|
||||
if not w.isVisible():
|
||||
w.move(-9999, -9999)
|
||||
continue
|
||||
iw = w.width() if w.width() > 0 else ITEM_W
|
||||
ih = w.height() if w.height() > 0 else ITEM_H
|
||||
if row_h > 0 and x + iw + GAP > avail_w:
|
||||
x = GAP
|
||||
y += row_h + GAP
|
||||
row_h = 0
|
||||
w.move(x, y)
|
||||
x += iw + GAP
|
||||
row_h = max(row_h, ih)
|
||||
|
||||
visible = [w for w in self._widgets if w.isVisible()]
|
||||
total_h = (y + row_h + GAP) if visible else 60
|
||||
self.setFixedHeight(max(total_h, 0))
|
||||
|
||||
def index_at(self, pos: QPoint) -> int:
|
||||
"""拖拽插入位置:与流式布局顺序一致,同行每个图标单独判断,不再只认行内第一个。"""
|
||||
visible = [w for w in self._widgets if w.isVisible()]
|
||||
if not visible:
|
||||
return 0
|
||||
|
||||
if pos.y() < visible[0].geometry().top():
|
||||
return 0
|
||||
|
||||
for w in visible:
|
||||
g = w.geometry()
|
||||
if pos.y() < g.top():
|
||||
return self._widgets.index(w)
|
||||
if g.top() <= pos.y() <= g.bottom():
|
||||
if pos.x() < g.left():
|
||||
return self._widgets.index(w)
|
||||
if pos.x() <= g.right():
|
||||
mid = (g.left() + g.right()) // 2
|
||||
if pos.x() < mid:
|
||||
return self._widgets.index(w)
|
||||
return self._widgets.index(w) + 1
|
||||
# 落在该行、但在本图标右侧,继续看下一个(同行或下一行)
|
||||
return len(self._widgets)
|
||||
|
||||
def _clear_selection_except(self, keep):
|
||||
for w in self._widgets:
|
||||
if w is not keep and hasattr(w, "_set_selected"):
|
||||
w._set_selected(False)
|
||||
|
||||
def clear_selection(self):
|
||||
self._clear_selection_except(None)
|
||||
|
||||
def _begin_rubber(self, pos: QPoint):
|
||||
self.clear_selection()
|
||||
self._rubber_active = True
|
||||
self._rubber_origin = pos
|
||||
self._rubber_rect = QRect(pos, QSize())
|
||||
self.grabMouse()
|
||||
self.update()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
ch = self.childAt(event.pos())
|
||||
if ch is None:
|
||||
self._begin_rubber(event.pos())
|
||||
return
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if self._rubber_active:
|
||||
self._rubber_rect = QRect(self._rubber_origin, event.pos()).normalized()
|
||||
self.update()
|
||||
return
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if self._rubber_active and event.button() == Qt.MouseButton.LeftButton:
|
||||
self._rubber_active = False
|
||||
self.releaseMouse()
|
||||
r = self._rubber_rect
|
||||
self._rubber_rect = None
|
||||
self.update()
|
||||
if r is not None and r.width() >= RUBBER_MIN and r.height() >= RUBBER_MIN:
|
||||
self.clear_selection()
|
||||
for w in self._widgets:
|
||||
if not w.isVisible():
|
||||
continue
|
||||
if hasattr(w, "_set_selected") and r.intersects(w.geometry()):
|
||||
w._set_selected(True)
|
||||
return
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def paintEvent(self, event):
|
||||
super().paintEvent(event)
|
||||
need_drag = self._drag_highlight
|
||||
need_rubber = self._rubber_rect is not None and self._rubber_active
|
||||
if not need_drag and not need_rubber:
|
||||
return
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
if need_drag:
|
||||
pen = QPen(QColor("#4a9eff"), 2, Qt.PenStyle.DashLine)
|
||||
pen.setDashPattern([6, 4])
|
||||
p.setPen(pen)
|
||||
p.setBrush(QColor(74, 158, 255, 25))
|
||||
p.drawRoundedRect(2, 2, self.width() - 4, self.height() - 4, 6, 6)
|
||||
if need_rubber:
|
||||
pen = QPen(QColor("#4a9eff"), 1, Qt.PenStyle.DashLine)
|
||||
p.setPen(pen)
|
||||
p.setBrush(QColor(74, 158, 255, 40))
|
||||
p.drawRoundedRect(self._rubber_rect, 2, 2)
|
||||
p.end()
|
||||
546
ui/group.py
Normal file
@ -0,0 +1,546 @@
|
||||
import os
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QMessageBox, QMenu, QSizePolicy
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QSize, QPoint, QMimeData, QByteArray
|
||||
from PyQt6.QtGui import (
|
||||
QDragEnterEvent, QDragMoveEvent, QDropEvent, QCursor, QDrag, QPainter, QColor, QPen,
|
||||
QShortcut, QKeySequence,
|
||||
)
|
||||
from db import database
|
||||
from ui.item import ItemWidget
|
||||
from ui.flow_layout import FlowContainer
|
||||
import ui.theme as theme
|
||||
import qtawesome as qta
|
||||
import shortcut_target
|
||||
import ui.dialog_style as dialog_style
|
||||
|
||||
|
||||
def item_count_for_group(group_id: int) -> int:
|
||||
"""分组内程序/文件总数(文件夹分组数磁盘文件,普通分组数数据库条目)。"""
|
||||
groups = database.get_groups()
|
||||
group_info = next((g for g in groups if g["id"] == group_id), {})
|
||||
folder_path = (group_info.get("folder_path", "") or "").strip()
|
||||
if folder_path and os.path.isdir(folder_path):
|
||||
n = 0
|
||||
try:
|
||||
import stat as _stat
|
||||
for fname in os.listdir(folder_path):
|
||||
fpath = os.path.join(folder_path, fname)
|
||||
try:
|
||||
if _stat.S_ISREG(os.stat(fpath).st_mode):
|
||||
n += 1
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
return n
|
||||
return len(database.get_items(group_id))
|
||||
|
||||
|
||||
class DragHandle(QLabel):
|
||||
"""分组拖拽排序手柄"""
|
||||
|
||||
def __init__(self, group_widget, parent=None):
|
||||
super().__init__(parent)
|
||||
self.group_widget = group_widget
|
||||
self.setFixedSize(18, 18)
|
||||
self.setCursor(Qt.CursorShape.SizeVerCursor)
|
||||
self.setToolTip("拖拽排序")
|
||||
self.setPixmap(qta.icon("fa5s.grip-vertical", color="#888").pixmap(14, 14))
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self._drag_start = event.pos()
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if not (event.buttons() & Qt.MouseButton.LeftButton):
|
||||
return
|
||||
if (event.pos() - self._drag_start).manhattanLength() < 6:
|
||||
return
|
||||
drag = QDrag(self)
|
||||
mime = QMimeData()
|
||||
gid = self.group_widget.group_data["id"]
|
||||
mime.setData("application/x-group-id", QByteArray(str(gid).encode()))
|
||||
drag.setMimeData(mime)
|
||||
px = self.group_widget.header.grab()
|
||||
drag.setPixmap(px)
|
||||
drag.setHotSpot(QPoint(px.width() // 2, px.height() // 2))
|
||||
drag.exec(Qt.DropAction.MoveAction)
|
||||
|
||||
|
||||
class FlowWidget(QWidget):
|
||||
item_dropped = pyqtSignal(int)
|
||||
refreshed = pyqtSignal()
|
||||
|
||||
def __init__(self, group_id, parent=None):
|
||||
super().__init__(parent)
|
||||
self.group_id = group_id
|
||||
self.setAcceptDrops(True)
|
||||
self._drop_indicator = -1
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(2, 2, 2, 2)
|
||||
layout.setSpacing(0)
|
||||
|
||||
self._container = FlowContainer(self)
|
||||
layout.addWidget(self._container)
|
||||
layout.addStretch()
|
||||
|
||||
self.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
|
||||
_del_sc = QShortcut(QKeySequence.StandardKey.Delete, self)
|
||||
_del_sc.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
|
||||
_del_sc.activated.connect(self._delete_selected_items)
|
||||
_esc_sc = QShortcut(QKeySequence(Qt.Key.Key_Escape), self)
|
||||
_esc_sc.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
|
||||
_esc_sc.activated.connect(self._container.clear_selection)
|
||||
|
||||
self.refresh()
|
||||
|
||||
def refresh(self, keyword=""):
|
||||
self._container.clear_widgets()
|
||||
|
||||
# 判断是否是文件夹分组
|
||||
groups = database.get_groups()
|
||||
group_info = next((g for g in groups if g["id"] == self.group_id), {})
|
||||
folder_path = (group_info.get("folder_path", "") or "").strip()
|
||||
|
||||
if folder_path and os.path.isdir(folder_path):
|
||||
# 文件夹分组:直接读目录,不走数据库
|
||||
items = []
|
||||
try:
|
||||
for fname in sorted(os.listdir(folder_path)):
|
||||
fpath = os.path.join(folder_path, fname)
|
||||
try:
|
||||
import stat as _stat
|
||||
if _stat.S_ISREG(os.stat(fpath).st_mode):
|
||||
name = os.path.splitext(fname)[0] or fname
|
||||
items.append({"id": None, "name": name,
|
||||
"path": fpath, "group_id": self.group_id})
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# 普通分组:走数据库
|
||||
items = database.get_items(self.group_id)
|
||||
|
||||
for data in items:
|
||||
iw = ItemWidget(data, self._container)
|
||||
iw.show()
|
||||
if keyword and not iw.matches(keyword):
|
||||
iw.hide()
|
||||
self._container.add_widget(iw)
|
||||
|
||||
self._container._schedule_relayout()
|
||||
self.updateGeometry()
|
||||
self.refreshed.emit()
|
||||
|
||||
def apply_theme(self):
|
||||
for w in self._container.get_widgets():
|
||||
w._apply_theme()
|
||||
|
||||
def filter(self, keyword: str):
|
||||
# 不重建,只切换可见性,然后重新布局
|
||||
for w in self._container.get_widgets():
|
||||
if keyword:
|
||||
w.setVisible(w.matches(keyword))
|
||||
else:
|
||||
w.show()
|
||||
self._container._relayout()
|
||||
self.updateGeometry()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
cg = self._container.geometry()
|
||||
if not cg.contains(event.pos()):
|
||||
lp = self._container.mapFrom(self, event.pos())
|
||||
w, h = max(1, self._container.width()), max(1, self._container.height())
|
||||
lp.setX(max(0, min(lp.x(), w - 1)))
|
||||
lp.setY(max(0, min(lp.y(), h - 1)))
|
||||
self._container._begin_rubber(lp)
|
||||
return
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def _delete_selected_items(self):
|
||||
widgets = [w for w in self._container.get_widgets() if getattr(w, "_selected", False)]
|
||||
if not widgets:
|
||||
return
|
||||
ret = dialog_style.question(
|
||||
self,
|
||||
"批量删除",
|
||||
f"确定删除选中的 {len(widgets)} 项?",
|
||||
default_button=QMessageBox.StandardButton.No,
|
||||
)
|
||||
if ret != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
for w in widgets:
|
||||
w._delete_item(skip_confirm=True, skip_refresh=True)
|
||||
self.refresh()
|
||||
|
||||
def dragEnterEvent(self, event: QDragEnterEvent):
|
||||
if event.mimeData().hasFormat("application/x-item-id") or \
|
||||
event.mimeData().hasUrls():
|
||||
self._container.set_drag_highlight(True)
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def dragMoveEvent(self, event: QDragMoveEvent):
|
||||
if event.mimeData().hasFormat("application/x-item-id"):
|
||||
pos = self._container.mapFrom(self, event.position().toPoint())
|
||||
self._drop_indicator = self._container.index_at(pos)
|
||||
self.update()
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dragLeaveEvent(self, event):
|
||||
self._container.set_drag_highlight(False)
|
||||
self._drop_indicator = -1
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
super().paintEvent(event)
|
||||
if self._drop_indicator < 0:
|
||||
return
|
||||
from PyQt6.QtGui import QPainter, QColor, QPen
|
||||
all_w = self._container.get_widgets()
|
||||
if not all_w:
|
||||
return
|
||||
pos = self._drop_indicator
|
||||
p = QPainter(self)
|
||||
p.setPen(QPen(QColor("#4a9eff"), 2))
|
||||
offset = self._container.pos()
|
||||
|
||||
def _draw_vline(x_local: int, g):
|
||||
x = offset.x() + x_local
|
||||
y1 = offset.y() + g.top()
|
||||
y2 = offset.y() + g.bottom()
|
||||
p.drawLine(x, y1, x, y2)
|
||||
|
||||
if pos >= len(all_w):
|
||||
for j in range(len(all_w) - 1, -1, -1):
|
||||
if all_w[j].isVisible():
|
||||
g = all_w[j].geometry()
|
||||
_draw_vline(g.right() + 2, g)
|
||||
break
|
||||
p.end()
|
||||
return
|
||||
|
||||
w = all_w[pos]
|
||||
if w.isVisible():
|
||||
g = w.geometry()
|
||||
_draw_vline(g.left() - 2, g)
|
||||
else:
|
||||
placed = False
|
||||
for j in range(pos, len(all_w)):
|
||||
if all_w[j].isVisible():
|
||||
g = all_w[j].geometry()
|
||||
_draw_vline(g.left() - 2, g)
|
||||
placed = True
|
||||
break
|
||||
if not placed:
|
||||
for j in range(pos - 1, -1, -1):
|
||||
if all_w[j].isVisible():
|
||||
g = all_w[j].geometry()
|
||||
_draw_vline(g.right() + 2, g)
|
||||
break
|
||||
p.end()
|
||||
|
||||
def dropEvent(self, event: QDropEvent):
|
||||
self._container.set_drag_highlight(False)
|
||||
self._drop_indicator = -1
|
||||
self.update()
|
||||
mime = event.mimeData()
|
||||
if mime.hasFormat("application/x-item-id"):
|
||||
item_id = int(mime.data("application/x-item-id").data().decode())
|
||||
pos = self._container.mapFrom(self, event.position().toPoint())
|
||||
insert_pos = self._container.index_at(pos)
|
||||
database.move_item(item_id, self.group_id, 999)
|
||||
items = database.get_items(self.group_id)
|
||||
ids = [it["id"] for it in items if it["id"] != item_id]
|
||||
ids.insert(min(insert_pos, len(ids)), item_id)
|
||||
database.reorder_items(self.group_id, ids)
|
||||
self.refresh()
|
||||
self.item_dropped.emit(item_id)
|
||||
event.acceptProposedAction()
|
||||
elif mime.hasUrls():
|
||||
for url in mime.urls():
|
||||
path = url.toLocalFile()
|
||||
if path:
|
||||
store = shortcut_target.path_for_storage(path)
|
||||
name = shortcut_target.item_name_from_sources(path, store)
|
||||
database.add_item(self.group_id, name, store)
|
||||
self.refresh()
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
t = theme.current()
|
||||
menu_style = f"""
|
||||
QMenu {{ background:{t['menu_bg']}; color:{t['menu_color']};
|
||||
border:1px solid {t['menu_border']}; padding:4px; border-radius:6px; }}
|
||||
QMenu::item {{ padding:5px 16px; border-radius:3px; }}
|
||||
QMenu::item:selected {{ background:{t['menu_selected']}; }}
|
||||
QMenu::item:disabled {{ color:#666; }}
|
||||
"""
|
||||
menu = QMenu(self)
|
||||
menu.setStyleSheet(menu_style)
|
||||
|
||||
refresh_act = menu.addAction("🔄 刷新分组")
|
||||
menu.addSeparator()
|
||||
|
||||
# 检测剪贴板
|
||||
clipboard = QApplication.clipboard()
|
||||
mime = clipboard.mimeData()
|
||||
paste_act = None
|
||||
clipboard_paths = []
|
||||
|
||||
if mime and mime.hasUrls():
|
||||
# 获取本分组信息
|
||||
groups = database.get_groups()
|
||||
group_info = next((g for g in groups if g["id"] == self.group_id), {})
|
||||
is_folder_group = bool(group_info.get("folder_path", ""))
|
||||
|
||||
# 快捷方式扩展名
|
||||
shortcut_exts = {".exe", ".lnk", ".bat", ".cmd", ".url"}
|
||||
|
||||
for url in mime.urls():
|
||||
path = url.toLocalFile()
|
||||
if not path:
|
||||
continue
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if is_folder_group:
|
||||
# 文件夹分组:接受所有文件
|
||||
if os.path.isfile(path):
|
||||
clipboard_paths.append(path)
|
||||
else:
|
||||
# 普通分组:只接受快捷方式/程序
|
||||
if ext in shortcut_exts:
|
||||
clipboard_paths.append(path)
|
||||
|
||||
if clipboard_paths:
|
||||
label = f"📋 粘贴 {len(clipboard_paths)} 个文件" if len(clipboard_paths) > 1 \
|
||||
else f"📋 粘贴「{os.path.basename(clipboard_paths[0])}」"
|
||||
paste_act = menu.addAction(label)
|
||||
else:
|
||||
act = menu.addAction("📋 粘贴(无可用文件)")
|
||||
act.setEnabled(False)
|
||||
else:
|
||||
act = menu.addAction("📋 粘贴(剪贴板为空)")
|
||||
act.setEnabled(False)
|
||||
|
||||
action = menu.exec(event.globalPos())
|
||||
if action == refresh_act:
|
||||
self.refresh()
|
||||
elif paste_act and action == paste_act:
|
||||
groups = database.get_groups()
|
||||
group_info = next((g for g in groups if g["id"] == self.group_id), {})
|
||||
folder_path = group_info.get("folder_path", "")
|
||||
is_folder_group = bool(folder_path)
|
||||
|
||||
for path in clipboard_paths:
|
||||
if is_folder_group:
|
||||
# 真正复制文件到文件夹目录
|
||||
import shutil
|
||||
dest_path = os.path.join(folder_path, os.path.basename(path))
|
||||
# 避免覆盖同名文件,自动重命名
|
||||
if os.path.exists(dest_path) and dest_path != path:
|
||||
base, ext = os.path.splitext(os.path.basename(path))
|
||||
i = 1
|
||||
while os.path.exists(dest_path):
|
||||
dest_path = os.path.join(folder_path, f"{base} ({i}){ext}")
|
||||
i += 1
|
||||
try:
|
||||
if path != dest_path:
|
||||
shutil.copy2(path, dest_path)
|
||||
final_path = dest_path
|
||||
except Exception as e:
|
||||
print(f"复制失败: {path} -> {e}")
|
||||
final_path = path # 复制失败就用原路径
|
||||
name = os.path.splitext(os.path.basename(final_path))[0] or os.path.basename(final_path)
|
||||
else:
|
||||
final_path = shortcut_target.path_for_storage(path)
|
||||
name = shortcut_target.item_name_from_sources(path, final_path)
|
||||
|
||||
database.add_item(self.group_id, name, final_path)
|
||||
self.refresh()
|
||||
|
||||
|
||||
class GroupWidget(QWidget):
|
||||
request_refresh_all = pyqtSignal()
|
||||
|
||||
def __init__(self, group_data: dict, parent=None):
|
||||
super().__init__(parent)
|
||||
self.group_data = group_data
|
||||
self.collapsed = False
|
||||
self._build_ui()
|
||||
self._apply_theme()
|
||||
|
||||
def _build_ui(self):
|
||||
self.main_layout = QVBoxLayout(self)
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 4)
|
||||
self.main_layout.setSpacing(0)
|
||||
|
||||
self.header = QWidget()
|
||||
self.header.setFixedHeight(32)
|
||||
self.header.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.header.mousePressEvent = lambda e: self._toggle() \
|
||||
if e.button() == Qt.MouseButton.LeftButton else None
|
||||
|
||||
h_layout = QHBoxLayout(self.header)
|
||||
h_layout.setContentsMargins(4, 0, 6, 0)
|
||||
h_layout.setSpacing(4)
|
||||
|
||||
# 拖拽排序手柄
|
||||
self._drag_handle = DragHandle(self)
|
||||
|
||||
self.toggle_icon = QLabel()
|
||||
self.toggle_icon.setFixedSize(14, 14)
|
||||
self.toggle_icon.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
|
||||
self.title_label = QLabel()
|
||||
self.title_label.setStyleSheet("font-size:12px; font-weight:bold; background:transparent;")
|
||||
self.title_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
|
||||
folder_path = self.group_data.get("folder_path", "") or ""
|
||||
# 只显示最后两级路径,避免太长
|
||||
if folder_path:
|
||||
parts = folder_path.replace("\\", "/").rstrip("/").split("/")
|
||||
display_path = "/".join(parts[-2:]) if len(parts) >= 2 else folder_path
|
||||
else:
|
||||
display_path = ""
|
||||
self.path_label = QLabel(display_path)
|
||||
self.path_label.setStyleSheet("font-size:9px; color:#888; background:transparent;")
|
||||
self.path_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
self.path_label.setVisible(bool(folder_path))
|
||||
self.path_label.setMaximumWidth(110)
|
||||
self.path_label.setToolTip(folder_path)
|
||||
self.path_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
self._add_btn = QPushButton()
|
||||
self._add_btn.setFixedSize(22, 22)
|
||||
self._add_btn.setToolTip("添加程序")
|
||||
self._add_btn.setStyleSheet("border:none; background:transparent;")
|
||||
self._add_btn.clicked.connect(self._add_item_dialog)
|
||||
self._add_btn.mousePressEvent = lambda e: (
|
||||
self._add_btn.click() if e.button() == Qt.MouseButton.LeftButton else None
|
||||
)
|
||||
|
||||
self._more_btn = QPushButton()
|
||||
self._more_btn.setFixedSize(22, 22)
|
||||
self._more_btn.setStyleSheet("border:none; background:transparent;")
|
||||
self._more_btn.clicked.connect(self._group_menu)
|
||||
|
||||
h_layout.addWidget(self._drag_handle)
|
||||
h_layout.addWidget(self.toggle_icon)
|
||||
h_layout.addWidget(self.title_label)
|
||||
h_layout.addStretch()
|
||||
h_layout.addWidget(self.path_label)
|
||||
h_layout.addWidget(self._add_btn)
|
||||
h_layout.addWidget(self._more_btn)
|
||||
|
||||
self.flow = FlowWidget(self.group_data["id"])
|
||||
self.flow.item_dropped.connect(lambda _: self.request_refresh_all.emit())
|
||||
self.flow.refreshed.connect(self._update_title_count)
|
||||
self._update_title_count()
|
||||
|
||||
self.main_layout.addWidget(self.header)
|
||||
self.main_layout.addWidget(self.flow)
|
||||
|
||||
def _update_title_count(self):
|
||||
n = item_count_for_group(self.group_data["id"])
|
||||
self.title_label.setText(f"{self.group_data['name']} ({n})")
|
||||
|
||||
def _apply_theme(self):
|
||||
t = theme.current()
|
||||
ic = "#cccccc" if theme.name() == "dark" else "#555555"
|
||||
|
||||
self.header.setStyleSheet(f"""
|
||||
QWidget {{ background:{t['header_bg']}; border-radius:5px; }}
|
||||
QWidget:hover {{ background:{t['header_hover']}; }}
|
||||
""")
|
||||
self.title_label.setStyleSheet(
|
||||
f"color:{t['item_name_color']}; font-size:12px; font-weight:bold; background:transparent;"
|
||||
)
|
||||
# 展开/收缩图标
|
||||
arrow = "fa5s.chevron-down" if not self.collapsed else "fa5s.chevron-right"
|
||||
self.toggle_icon.setPixmap(qta.icon(arrow, color=ic).pixmap(14, 14))
|
||||
|
||||
self._add_btn.setIcon(qta.icon("fa5s.plus", color=ic))
|
||||
self._add_btn.setIconSize(QSize(11, 11))
|
||||
self._more_btn.setIcon(qta.icon("fa5s.ellipsis-h", color=ic))
|
||||
self._more_btn.setIconSize(QSize(11, 11))
|
||||
self._drag_handle.setPixmap(qta.icon("fa5s.grip-vertical", color=ic).pixmap(14, 14))
|
||||
path_color = "#777" if theme.name() == "dark" else "#999"
|
||||
self.path_label.setStyleSheet(
|
||||
f"font-size:9px; color:{path_color}; background:transparent;"
|
||||
)
|
||||
|
||||
self.flow.apply_theme()
|
||||
|
||||
def _toggle(self):
|
||||
self.collapsed = not self.collapsed
|
||||
self.flow.setVisible(not self.collapsed)
|
||||
if not self.collapsed:
|
||||
self.flow.updateGeometry()
|
||||
self.updateGeometry()
|
||||
# 更新箭头图标
|
||||
t = theme.current()
|
||||
ic = "#cccccc" if theme.name() == "dark" else "#555555"
|
||||
arrow = "fa5s.chevron-down" if not self.collapsed else "fa5s.chevron-right"
|
||||
self.toggle_icon.setPixmap(qta.icon(arrow, color=ic).pixmap(14, 14))
|
||||
|
||||
def _add_item_dialog(self):
|
||||
path, _ = dialog_style.get_open_file_name(
|
||||
self,
|
||||
"选择程序或快捷方式",
|
||||
"",
|
||||
"程序/快捷方式 (*.exe *.lnk *.bat *.cmd *.url);;所有文件 (*)",
|
||||
)
|
||||
if path:
|
||||
store = shortcut_target.path_for_storage(path)
|
||||
name = shortcut_target.item_name_from_sources(path, store)
|
||||
database.add_item(self.group_data["id"], name, store)
|
||||
self.flow.refresh()
|
||||
|
||||
def _group_menu(self):
|
||||
t = theme.current()
|
||||
menu = QMenu(self)
|
||||
menu.setStyleSheet(f"""
|
||||
QMenu {{ background:{t['menu_bg']}; color:{t['menu_color']};
|
||||
border:1px solid {t['menu_border']}; padding:4px; border-radius:6px; }}
|
||||
QMenu::item {{ padding:5px 16px; border-radius:3px; }}
|
||||
QMenu::item:selected {{ background:{t['menu_selected']}; }}
|
||||
""")
|
||||
rename_act = menu.addAction("重命名")
|
||||
del_act = menu.addAction("删除分组")
|
||||
action = menu.exec(QCursor.pos())
|
||||
if action == rename_act:
|
||||
name, ok = dialog_style.get_text(
|
||||
self, "重命名", "新名称:", text=self.group_data["name"]
|
||||
)
|
||||
if ok and name.strip():
|
||||
database.rename_group(self.group_data["id"], name.strip())
|
||||
self.group_data["name"] = name.strip()
|
||||
self._update_title_count()
|
||||
elif action == del_act:
|
||||
ret = dialog_style.question(
|
||||
self,
|
||||
"确认",
|
||||
f"删除分组「{self.group_data['name']}」及其所有程序?",
|
||||
default_button=QMessageBox.StandardButton.No,
|
||||
)
|
||||
if ret == QMessageBox.StandardButton.Yes:
|
||||
database.delete_group(self.group_data["id"])
|
||||
self.request_refresh_all.emit()
|
||||
|
||||
def refresh(self, keyword=""):
|
||||
self.flow.refresh(keyword)
|
||||
|
||||
def filter(self, keyword: str):
|
||||
self.flow.filter(keyword)
|
||||
if keyword and self.collapsed:
|
||||
self._toggle()
|
||||
389
ui/item.py
Normal file
@ -0,0 +1,389 @@
|
||||
import os
|
||||
import sys
|
||||
import qtawesome as qta
|
||||
from PyQt6.QtWidgets import (QWidget, QLabel, QVBoxLayout, QSizePolicy,
|
||||
QMenu, QFileIconProvider, QMessageBox)
|
||||
from PyQt6.QtGui import (
|
||||
QDrag, QPixmap, QPainter, QColor, QIcon, QPen, QKeySequence,
|
||||
QFont, QFontMetrics, QTextLayout, QTextOption,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QMimeData, QByteArray, QSize, QPoint, QFileInfo, QPropertyAnimation, QEasingCurve
|
||||
from db import database
|
||||
import ui.theme as theme
|
||||
import ui.dialog_style as dialog_style
|
||||
import shortcut_target
|
||||
|
||||
_icon_provider = QFileIconProvider()
|
||||
|
||||
|
||||
def _wrapped_line_count(text: str, font: QFont, width_px: int) -> int:
|
||||
if width_px < 4 or not text:
|
||||
return 0 if not text else 999
|
||||
tl = QTextLayout(text, font)
|
||||
opt = QTextOption()
|
||||
opt.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)
|
||||
tl.setTextOption(opt)
|
||||
tl.beginLayout()
|
||||
n = 0
|
||||
while True:
|
||||
line = tl.createLine()
|
||||
if not line.isValid():
|
||||
break
|
||||
line.setLineWidth(width_px)
|
||||
n += 1
|
||||
tl.endLayout()
|
||||
return n
|
||||
|
||||
|
||||
def two_line_display_text(text: str, font: QFont, width_px: int) -> str:
|
||||
"""最多按宽度折成 2 行;超出部分用省略号。"""
|
||||
if not text:
|
||||
return text
|
||||
if _wrapped_line_count(text, font, width_px) <= 2:
|
||||
return text
|
||||
elide = "…"
|
||||
lo, hi = 0, len(text)
|
||||
best = elide
|
||||
while lo <= hi:
|
||||
mid = (lo + hi) // 2
|
||||
cand = text[:mid].rstrip() + elide
|
||||
if _wrapped_line_count(cand, font, width_px) <= 2:
|
||||
best = cand
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid - 1
|
||||
return best
|
||||
|
||||
|
||||
def extract_icon(path: str) -> QIcon:
|
||||
try:
|
||||
icon = _icon_provider.icon(QFileInfo(path))
|
||||
if not icon.isNull():
|
||||
px = icon.pixmap(QSize(36, 36))
|
||||
if not px.isNull() and px.width() > 4:
|
||||
return icon
|
||||
except Exception:
|
||||
pass
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
color = "#4a9eff"
|
||||
if ext == ".exe": return qta.icon("fa5s.desktop", color=color)
|
||||
if ext == ".lnk": return qta.icon("fa5s.link", color=color)
|
||||
if ext in (".bat", ".cmd"): return qta.icon("fa5s.terminal", color=color)
|
||||
if ext == ".url": return qta.icon("fa5s.globe", color=color)
|
||||
return qta.icon("fa5s.file", color=color)
|
||||
|
||||
|
||||
class ItemWidget(QWidget):
|
||||
DRAG_THRESHOLD = 8
|
||||
|
||||
def __init__(self, item_data: dict, parent=None):
|
||||
super().__init__(parent)
|
||||
self.item_data = item_data
|
||||
self.setFixedSize(68, 76)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self._drag_start_pos = QPoint()
|
||||
self._selected = False
|
||||
self._build_ui()
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
self._update_name_display()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self._update_name_display()
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(4, 6, 4, 4)
|
||||
layout.setSpacing(3)
|
||||
|
||||
icon = extract_icon(self.item_data["path"])
|
||||
self.icon_label = QLabel()
|
||||
self.icon_label.setPixmap(icon.pixmap(QSize(36, 36)))
|
||||
self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.icon_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
|
||||
self.name_label = QLabel()
|
||||
self.name_label.setAlignment(Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignTop)
|
||||
self.name_label.setWordWrap(True)
|
||||
self.name_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
self.name_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
self._apply_theme()
|
||||
|
||||
layout.addWidget(self.icon_label)
|
||||
layout.addWidget(self.name_label)
|
||||
|
||||
def _update_name_display(self):
|
||||
name = self.item_data.get("name") or ""
|
||||
w = max(8, self.width() - 8)
|
||||
font = self.name_label.font()
|
||||
disp = two_line_display_text(name, font, w)
|
||||
self.name_label.setText(disp)
|
||||
path = self.item_data.get("path") or ""
|
||||
self.setToolTip(f"{name}\n{path}" if path else name)
|
||||
|
||||
def _apply_theme(self):
|
||||
t = theme.current()
|
||||
self.name_label.setStyleSheet(f"font-size:10px; color:{t['item_name_color']};")
|
||||
fm = QFontMetrics(self.name_label.font())
|
||||
self.name_label.setMaximumHeight(int(fm.lineSpacing() * 2 + 4))
|
||||
self._update_name_display()
|
||||
|
||||
def matches(self, keyword: str) -> bool:
|
||||
return keyword.lower() in self.item_data["name"].lower()
|
||||
|
||||
# ── 选中高亮 ─────────────────────────────────────────
|
||||
def _set_selected(self, val: bool):
|
||||
self._selected = val
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
if self._selected:
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
p.setBrush(QColor(74, 158, 255, 50))
|
||||
p.setPen(QPen(QColor("#4a9eff"), 1.5))
|
||||
p.drawRoundedRect(1, 1, self.width() - 2, self.height() - 2, 6, 6)
|
||||
p.end()
|
||||
super().paintEvent(event)
|
||||
|
||||
# ── 鼠标事件 ─────────────────────────────────────────
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self._launch()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self._drag_start_pos = event.pos()
|
||||
fc = self.parent()
|
||||
if fc and hasattr(fc, "_clear_selection_except"):
|
||||
fc._clear_selection_except(self)
|
||||
self._set_selected(True)
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if not (event.buttons() & Qt.MouseButton.LeftButton):
|
||||
return
|
||||
if (event.pos() - self._drag_start_pos).manhattanLength() < self.DRAG_THRESHOLD:
|
||||
return
|
||||
self._set_selected(False)
|
||||
drag = QDrag(self)
|
||||
mime = QMimeData()
|
||||
item_id = self.item_data.get("id")
|
||||
id_bytes = str(item_id).encode() if item_id is not None else b"none"
|
||||
mime.setData("application/x-item-id", QByteArray(id_bytes))
|
||||
# 也携带文件路径,方便跨组拖拽文件夹分组的 item
|
||||
from PyQt6.QtCore import QUrl
|
||||
mime.setUrls([QUrl.fromLocalFile(self.item_data["path"])])
|
||||
drag.setMimeData(mime)
|
||||
|
||||
src = self.grab()
|
||||
scale = 1.4
|
||||
pw, ph = int(src.width() * scale), int(src.height() * scale)
|
||||
preview = QPixmap(pw, ph)
|
||||
preview.fill(QColor(0, 0, 0, 0))
|
||||
painter = QPainter(preview)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
||||
painter.setBrush(QColor(60, 60, 60, 160))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawRoundedRect(0, 0, pw, ph, 8, 8)
|
||||
painter.setOpacity(0.92)
|
||||
painter.drawPixmap(0, 0, pw, ph, src)
|
||||
painter.end()
|
||||
drag.setPixmap(preview)
|
||||
drag.setHotSpot(QPoint(int(event.pos().x() * scale), int(event.pos().y() * scale)))
|
||||
drag.exec(Qt.DropAction.MoveAction)
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
fc = self.parent()
|
||||
if fc and hasattr(fc, "_clear_selection_except"):
|
||||
if not self._selected:
|
||||
fc._clear_selection_except(self)
|
||||
self._set_selected(True)
|
||||
else:
|
||||
self._set_selected(True)
|
||||
|
||||
t = theme.current()
|
||||
menu_style = f"""
|
||||
QMenu {{ background:{t['menu_bg']}; color:{t['menu_color']};
|
||||
border:1px solid {t['menu_border']}; padding:4px; border-radius:6px; }}
|
||||
QMenu::item {{ padding:6px 18px; border-radius:3px; }}
|
||||
QMenu::item:selected {{ background:{t['menu_selected']}; }}
|
||||
"""
|
||||
menu = QMenu(self)
|
||||
menu.setStyleSheet(menu_style)
|
||||
|
||||
open_act = menu.addAction(qta.icon("fa5s.play", color="#4a9eff"), "打开")
|
||||
menu.addSeparator()
|
||||
|
||||
item_id = self.item_data.get("id")
|
||||
is_folder_item = item_id is None
|
||||
|
||||
rename_act = menu.addAction(qta.icon("fa5s.pen", color="#aaa"), "重命名")
|
||||
if not is_folder_item:
|
||||
repath_act = menu.addAction(qta.icon("fa5s.folder-open", color="#aaa"), "更换路径")
|
||||
else:
|
||||
repath_act = None
|
||||
menu.addSeparator()
|
||||
del_act = menu.addAction(qta.icon("fa5s.trash", color="#e55"), "删除")
|
||||
|
||||
# 菜单弹出时单键触发:O 打开 / M 重命名 / D 删除
|
||||
_ctx = Qt.ShortcutContext.WidgetWithChildrenShortcut
|
||||
open_act.setShortcut(QKeySequence(Qt.Key.Key_O))
|
||||
open_act.setShortcutContext(_ctx)
|
||||
rename_act.setShortcut(QKeySequence(Qt.Key.Key_M))
|
||||
rename_act.setShortcutContext(_ctx)
|
||||
del_act.setShortcut(QKeySequence(Qt.Key.Key_D))
|
||||
del_act.setShortcutContext(_ctx)
|
||||
|
||||
action = menu.exec(event.globalPos())
|
||||
|
||||
if action == open_act:
|
||||
self._launch()
|
||||
elif action == rename_act:
|
||||
self._rename()
|
||||
elif repath_act and action == repath_act:
|
||||
self._change_path()
|
||||
elif action == del_act:
|
||||
self._delete_item()
|
||||
|
||||
# ── 操作 ─────────────────────────────────────────────
|
||||
def _rename(self):
|
||||
is_folder_item = self.item_data.get("id") is None
|
||||
old_path = self.item_data["path"]
|
||||
ext = os.path.splitext(old_path)[1]
|
||||
|
||||
# 文件夹分组显示带后缀的完整文件名,普通分组只显示名称
|
||||
if is_folder_item:
|
||||
current_display = self.item_data["name"] + ext
|
||||
else:
|
||||
current_display = self.item_data["name"]
|
||||
|
||||
name, ok = dialog_style.get_text(self, "重命名", "新名称:", text=current_display)
|
||||
if not ok or not name.strip() or name.strip() == current_display:
|
||||
return
|
||||
new_input = name.strip()
|
||||
|
||||
if is_folder_item:
|
||||
# 用户输入的就是完整文件名(含后缀)
|
||||
new_filename = new_input
|
||||
new_name = os.path.splitext(new_filename)[0] or new_filename
|
||||
new_path = os.path.join(os.path.dirname(old_path), new_filename)
|
||||
try:
|
||||
os.rename(old_path, new_path)
|
||||
self.item_data["path"] = new_path
|
||||
self.item_data["name"] = new_name
|
||||
self._update_name_display()
|
||||
except Exception as e:
|
||||
dialog_style.warning(self, "重命名失败", str(e))
|
||||
else:
|
||||
conn = database.get_conn()
|
||||
conn.execute("UPDATE items SET name=? WHERE id=?",
|
||||
(new_input, self.item_data["id"]))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
self.item_data["name"] = new_input
|
||||
self._update_name_display()
|
||||
|
||||
def _change_path(self):
|
||||
path, _ = dialog_style.get_open_file_name(
|
||||
self,
|
||||
"选择新路径",
|
||||
os.path.dirname(self.item_data["path"]),
|
||||
"程序/快捷方式 (*.exe *.lnk *.bat *.cmd *.url);;所有文件 (*)",
|
||||
)
|
||||
if path:
|
||||
store = shortcut_target.path_for_storage(path)
|
||||
conn = database.get_conn()
|
||||
conn.execute("UPDATE items SET path=? WHERE id=?",
|
||||
(store, self.item_data["id"]))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
self.item_data["path"] = store
|
||||
self.icon_label.setPixmap(extract_icon(store).pixmap(QSize(36, 36)))
|
||||
self._update_name_display()
|
||||
|
||||
def _delete_item(self, skip_confirm: bool = False, skip_refresh: bool = False):
|
||||
item_path = self.item_data["path"]
|
||||
item_id = self.item_data.get("id")
|
||||
is_folder_item = item_id is None
|
||||
|
||||
if is_folder_item and os.path.isfile(item_path):
|
||||
if not skip_confirm:
|
||||
ret = dialog_style.question(
|
||||
self,
|
||||
"删除确认",
|
||||
f"确认删除文件?\n\n{item_path}",
|
||||
buttons=QMessageBox.StandardButton.Yes
|
||||
| QMessageBox.StandardButton.Cancel,
|
||||
default_button=QMessageBox.StandardButton.Cancel,
|
||||
)
|
||||
if ret != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
try:
|
||||
try:
|
||||
import send2trash
|
||||
send2trash.send2trash(item_path)
|
||||
except ImportError:
|
||||
os.remove(item_path)
|
||||
except Exception as e:
|
||||
dialog_style.warning(self, "删除失败", str(e))
|
||||
return
|
||||
else:
|
||||
if not skip_confirm:
|
||||
ret = dialog_style.question(
|
||||
self,
|
||||
"确认",
|
||||
f"从分组中移除「{self.item_data['name']}」?",
|
||||
default_button=QMessageBox.StandardButton.No,
|
||||
)
|
||||
if ret != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
if item_id is not None:
|
||||
database.delete_item(item_id)
|
||||
|
||||
if skip_refresh:
|
||||
return
|
||||
p = self.parent()
|
||||
while p and not hasattr(p, "refresh"):
|
||||
p = p.parent()
|
||||
if p:
|
||||
p.refresh()
|
||||
|
||||
def _launch(self):
|
||||
path = (self.item_data.get("path") or "").strip()
|
||||
if not path:
|
||||
return
|
||||
lp = path.lower()
|
||||
if lp.startswith(("http://", "https://")):
|
||||
import webbrowser
|
||||
webbrowser.open(path)
|
||||
return
|
||||
|
||||
if sys.platform == "win32":
|
||||
path = os.path.normpath(path)
|
||||
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
os.startfile(path)
|
||||
else:
|
||||
import subprocess
|
||||
subprocess.Popen(["xdg-open", path])
|
||||
except OSError:
|
||||
try:
|
||||
import subprocess
|
||||
if sys.platform == "win32":
|
||||
# shell=True 无法直接“运行” .lnk,需交给 start 做关联打开
|
||||
subprocess.Popen(
|
||||
["cmd", "/c", "start", "", path],
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", path])
|
||||
except Exception as e:
|
||||
print(f"启动失败: {path!r} -> {e}")
|
||||
466
ui/settings_window.py
Normal file
@ -0,0 +1,466 @@
|
||||
import sys
|
||||
import os
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QHBoxLayout, QVBoxLayout, QListWidget, QListWidgetItem,
|
||||
QStackedWidget, QWidget, QLabel, QSlider, QPushButton,
|
||||
QCheckBox, QGroupBox, QFormLayout, QSpinBox, QFrame
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QSize
|
||||
from PyQt6.QtGui import QIcon, QPixmap
|
||||
import qtawesome as qta
|
||||
from db import database
|
||||
import ui.theme as theme
|
||||
|
||||
|
||||
class SettingsWindow(QDialog):
|
||||
def __init__(self, panel, parent=None):
|
||||
super().__init__(parent)
|
||||
self._panel = panel
|
||||
self.setWindowTitle("设置")
|
||||
# 适当放大:让页面内容更舒展,并限制最小拖拽尺寸
|
||||
self.setMinimumSize(500, 500)
|
||||
self.resize(760, 520)
|
||||
self.setWindowFlags(
|
||||
Qt.WindowType.Dialog |
|
||||
Qt.WindowType.WindowCloseButtonHint
|
||||
)
|
||||
self._build_ui()
|
||||
self._apply_theme()
|
||||
|
||||
def _build_ui(self):
|
||||
root = QHBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(0)
|
||||
|
||||
# ── 左侧导航 ─────────────────────────────────────
|
||||
self.nav = QListWidget()
|
||||
self.nav.setFixedWidth(130)
|
||||
self.nav.setSpacing(2)
|
||||
self.nav.setIconSize(QSize(18, 18))
|
||||
self.nav.setStyleSheet("""
|
||||
QListWidget { border:none; outline:none; padding:8px 4px; }
|
||||
QListWidget::item { height:36px; border-radius:6px;
|
||||
padding-left:10px; font-size:13px; }
|
||||
QListWidget::item:selected { background:#4a9eff; color:white; }
|
||||
QListWidget::item:hover:!selected { background:rgba(128,128,128,40); }
|
||||
""")
|
||||
|
||||
pages = [
|
||||
("fa5s.paint-brush", "外观"),
|
||||
("fa5s.window-maximize", "窗口"),
|
||||
("fa5s.rocket", "启动"),
|
||||
("fa5s.heart", "捐赠"),
|
||||
]
|
||||
for icon_name, label in pages:
|
||||
try:
|
||||
icon = qta.icon(icon_name, color="#888")
|
||||
item = QListWidgetItem(icon, label)
|
||||
except Exception:
|
||||
# 防止图标名不存在导致设置页直接报错
|
||||
icon = qta.icon("fa5s.rocket", color="#888")
|
||||
item = QListWidgetItem(icon, label)
|
||||
self.nav.addItem(item)
|
||||
self.nav.setCurrentRow(0)
|
||||
self.nav.currentRowChanged.connect(self._on_nav)
|
||||
|
||||
# ── 右侧内容区 ───────────────────────────────────
|
||||
self.stack = QStackedWidget()
|
||||
self.stack.addWidget(self._page_appearance())
|
||||
self.stack.addWidget(self._page_window())
|
||||
self.stack.addWidget(self._page_startup())
|
||||
self.stack.addWidget(self._page_donate())
|
||||
|
||||
# 分割线
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.Shape.VLine)
|
||||
line.setStyleSheet("color: rgba(128,128,128,40);")
|
||||
|
||||
root.addWidget(self.nav)
|
||||
root.addWidget(line)
|
||||
root.addWidget(self.stack)
|
||||
|
||||
# ── 外观页 ───────────────────────────────────────────
|
||||
def _page_appearance(self) -> QWidget:
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setContentsMargins(24, 20, 24, 20)
|
||||
layout.setSpacing(20)
|
||||
|
||||
layout.addWidget(self._section_title("主题"))
|
||||
|
||||
theme_row = QHBoxLayout()
|
||||
theme_row.setSpacing(12)
|
||||
|
||||
self._dark_btn = self._theme_card("🌙 暗色", "dark")
|
||||
self._light_btn = self._theme_card("☀️ 亮色", "light")
|
||||
self._dark_btn.clicked.connect(lambda: self._set_theme("dark"))
|
||||
self._light_btn.clicked.connect(lambda: self._set_theme("light"))
|
||||
theme_row.addWidget(self._dark_btn)
|
||||
theme_row.addWidget(self._light_btn)
|
||||
theme_row.addStretch()
|
||||
layout.addLayout(theme_row)
|
||||
self._update_theme_cards()
|
||||
|
||||
layout.addWidget(self._divider())
|
||||
layout.addWidget(self._section_title("透明度"))
|
||||
|
||||
opacity_row = QHBoxLayout()
|
||||
opacity_row.setSpacing(12)
|
||||
self._opacity_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self._opacity_slider.setRange(30, 100)
|
||||
saved = int(database.get_setting("panel_opacity", "100"))
|
||||
self._opacity_slider.setValue(saved)
|
||||
self._opacity_slider.setFixedWidth(220)
|
||||
self._opacity_val = QLabel(f"{saved}%")
|
||||
self._opacity_val.setFixedWidth(36)
|
||||
self._opacity_slider.valueChanged.connect(self._on_opacity)
|
||||
opacity_row.addWidget(self._opacity_slider)
|
||||
opacity_row.addWidget(self._opacity_val)
|
||||
opacity_row.addStretch()
|
||||
layout.addLayout(opacity_row)
|
||||
|
||||
layout.addStretch()
|
||||
return page
|
||||
|
||||
def _theme_card(self, text: str, theme_name: str) -> QPushButton:
|
||||
btn = QPushButton(text)
|
||||
btn.setFixedSize(110, 44)
|
||||
btn.setCheckable(True)
|
||||
btn.setProperty("theme_name", theme_name)
|
||||
return btn
|
||||
|
||||
def _update_theme_cards(self):
|
||||
cur = theme.name()
|
||||
for btn in (self._dark_btn, self._light_btn):
|
||||
active = btn.property("theme_name") == cur
|
||||
btn.setChecked(active)
|
||||
if active:
|
||||
btn.setStyleSheet("""
|
||||
QPushButton { background:#4a9eff; color:white;
|
||||
border-radius:8px; font-size:13px;
|
||||
border:2px solid #4a9eff; }
|
||||
""")
|
||||
else:
|
||||
t = theme.current()
|
||||
btn.setStyleSheet(f"""
|
||||
QPushButton {{ background:{t['search_bg']}; color:{t['search_color']};
|
||||
border-radius:8px; font-size:13px;
|
||||
border:1px solid {t['search_border']}; }}
|
||||
QPushButton:hover {{ border-color:#4a9eff; }}
|
||||
""")
|
||||
|
||||
def _set_theme(self, name: str):
|
||||
theme.set_theme(name)
|
||||
self._panel._apply_theme()
|
||||
self._update_theme_cards()
|
||||
self._apply_theme()
|
||||
|
||||
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 _page_window(self) -> QWidget:
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setContentsMargins(24, 20, 24, 20)
|
||||
layout.setSpacing(16)
|
||||
|
||||
layout.addWidget(self._section_title("窗口行为"))
|
||||
|
||||
self._pin_check = QCheckBox("固定面板(不自动隐藏)")
|
||||
self._pin_check.setChecked(self._panel._pinned)
|
||||
self._pin_check.toggled.connect(self._on_pin)
|
||||
layout.addWidget(self._pin_check)
|
||||
|
||||
layout.addWidget(self._divider())
|
||||
layout.addWidget(self._section_title("图标大小"))
|
||||
|
||||
size_row = QHBoxLayout()
|
||||
size_row.setSpacing(12)
|
||||
size_lbl = QLabel("图标宽度:")
|
||||
self._icon_size_spin = QSpinBox()
|
||||
self._icon_size_spin.setRange(48, 120)
|
||||
self._icon_size_spin.setSingleStep(4)
|
||||
saved_size = int(database.get_setting("icon_width", "72"))
|
||||
self._icon_size_spin.setValue(saved_size)
|
||||
self._icon_size_spin.setSuffix(" px")
|
||||
self._icon_size_spin.setFixedWidth(90)
|
||||
apply_btn = QPushButton("应用")
|
||||
apply_btn.setFixedWidth(60)
|
||||
apply_btn.clicked.connect(self._on_icon_size)
|
||||
size_row.addWidget(size_lbl)
|
||||
size_row.addWidget(self._icon_size_spin)
|
||||
size_row.addWidget(apply_btn)
|
||||
size_row.addStretch()
|
||||
layout.addLayout(size_row)
|
||||
|
||||
layout.addStretch()
|
||||
return page
|
||||
|
||||
def _on_pin(self, checked: bool):
|
||||
self._panel._pinned = checked
|
||||
ic = "#cccccc" if theme.name() == "dark" else "#555555"
|
||||
import qtawesome as qta
|
||||
self._panel.pin_btn.setIcon(
|
||||
qta.icon("fa5s.thumbtack", color="#f90" if checked else ic)
|
||||
)
|
||||
|
||||
def _on_icon_size(self):
|
||||
val = self._icon_size_spin.value()
|
||||
database.set_setting("icon_width", str(val))
|
||||
# 通知所有 ItemWidget 更新尺寸
|
||||
from ui.flow_layout import ITEM_W
|
||||
import ui.flow_layout as fl
|
||||
fl.ITEM_W = val
|
||||
self._panel.refresh_groups()
|
||||
|
||||
# ── 启动页 ───────────────────────────────────────────
|
||||
def _page_startup(self) -> QWidget:
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setContentsMargins(24, 20, 24, 20)
|
||||
layout.setSpacing(16)
|
||||
|
||||
layout.addWidget(self._section_title("开机启动"))
|
||||
|
||||
self._autostart_check = QCheckBox("开机自动启动")
|
||||
self._autostart_check.setChecked(self._is_autostart_enabled())
|
||||
self._autostart_check.toggled.connect(self._on_autostart)
|
||||
layout.addWidget(self._autostart_check)
|
||||
|
||||
layout.addWidget(self._divider())
|
||||
layout.addWidget(self._section_title("关于"))
|
||||
|
||||
about = QLabel("桌面文件整理 v1.0\n整理你的桌面快捷方式,保持桌面干净。")
|
||||
about.setStyleSheet("color:#888; font-size:12px; line-height:1.6;")
|
||||
layout.addWidget(about)
|
||||
|
||||
layout.addStretch()
|
||||
return page
|
||||
|
||||
# ── 捐赠页 ───────────────────────────────────────────
|
||||
def _page_donate(self) -> QWidget:
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setContentsMargins(24, 20, 24, 20)
|
||||
layout.setSpacing(16)
|
||||
|
||||
layout.addWidget(self._section_title("捐赠支持"))
|
||||
|
||||
info = QLabel("如果你喜欢这个软件,欢迎通过二维码支持开发者持续优化与维护。你的鼓励会成为重要的动力。<br>感谢你的支持!")
|
||||
info.setWordWrap(True)
|
||||
info.setStyleSheet("font-size:14px; line-height:1.1; background:transparent;")
|
||||
layout.addWidget(info)
|
||||
|
||||
layout.addWidget(self._divider())
|
||||
|
||||
card = QFrame()
|
||||
card.setObjectName("donate_card")
|
||||
|
||||
grid = QHBoxLayout(card)
|
||||
grid.setContentsMargins(14, 14, 14, 14)
|
||||
grid.setSpacing(18)
|
||||
|
||||
donate_dir = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)), "assets", "imgs", "donate"
|
||||
)
|
||||
wx_path = os.path.join(donate_dir, "wx.jpg")
|
||||
zfb_path = os.path.join(donate_dir, "zfb.jpg")
|
||||
|
||||
def _qr_column(img_path: str, caption: str) -> QVBoxLayout:
|
||||
col = QVBoxLayout()
|
||||
col.setContentsMargins(0, 0, 0, 0)
|
||||
col.setSpacing(8)
|
||||
|
||||
img = QLabel()
|
||||
img.setFixedSize(170, 170)
|
||||
img.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
img.setStyleSheet("background:transparent; border-radius:12px;")
|
||||
|
||||
pm = QPixmap(img_path)
|
||||
if not pm.isNull():
|
||||
img.setPixmap(
|
||||
pm.scaled(
|
||||
img.size(),
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
)
|
||||
|
||||
img.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
img.setToolTip("点击放大")
|
||||
# 直接覆盖点击事件:点击二维码弹出大图
|
||||
img.mousePressEvent = ( # type: ignore[assignment]
|
||||
lambda e, p=img_path: self._open_donate_image(p)
|
||||
)
|
||||
|
||||
cap = QLabel(caption)
|
||||
cap.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
cap.setStyleSheet("font-size:12px; font-weight:700; background:transparent;")
|
||||
|
||||
col.addWidget(img)
|
||||
col.addWidget(cap)
|
||||
return col
|
||||
|
||||
grid.addLayout(_qr_column(wx_path, "微信支持"))
|
||||
grid.addLayout(_qr_column(zfb_path, "支付宝支持"))
|
||||
layout.addWidget(card)
|
||||
|
||||
# 供 _apply_theme 动态设置样式
|
||||
self._donate_card = card
|
||||
return page
|
||||
|
||||
def _open_donate_image(self, img_path: str):
|
||||
"""点击二维码后的弹窗大图查看。"""
|
||||
t = theme.current()
|
||||
|
||||
d = QDialog(self)
|
||||
d.setWindowTitle("二维码放大查看")
|
||||
d.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowCloseButtonHint)
|
||||
d.setFixedSize(800, 600)
|
||||
d.setStyleSheet(f"""
|
||||
QDialog {{
|
||||
background: {t['panel_bg']};
|
||||
border: 1px solid {t['panel_border']};
|
||||
border-radius: 10px;
|
||||
color: {t['search_color']};
|
||||
}}
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout(d)
|
||||
layout.setContentsMargins(14, 14, 14, 14)
|
||||
layout.setSpacing(0)
|
||||
|
||||
lbl = QLabel()
|
||||
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
lbl.setStyleSheet("background:transparent;")
|
||||
lbl.setScaledContents(False) # 不拉伸,保持等比
|
||||
pm = QPixmap(img_path)
|
||||
if not pm.isNull():
|
||||
# 适配框体:保持等比缩放(不会被拉伸)
|
||||
target_w = d.width() - 28
|
||||
target_h = d.height() - 28
|
||||
lbl.setPixmap(
|
||||
pm.scaled(
|
||||
target_w,
|
||||
target_h,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
)
|
||||
|
||||
layout.addWidget(lbl)
|
||||
|
||||
d.exec()
|
||||
|
||||
def _is_autostart_enabled(self) -> bool:
|
||||
try:
|
||||
import winreg
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
r"Software\Microsoft\Windows\CurrentVersion\Run",
|
||||
0, winreg.KEY_READ
|
||||
)
|
||||
winreg.QueryValueEx(key, "DesktopOrganizer")
|
||||
winreg.CloseKey(key)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _on_autostart(self, checked: bool):
|
||||
from ui.dock import _set_autostart
|
||||
_set_autostart(checked)
|
||||
|
||||
# ── 工具方法 ─────────────────────────────────────────
|
||||
def _section_title(self, text: str) -> QLabel:
|
||||
lbl = QLabel(text)
|
||||
lbl.setStyleSheet("font-size:14px; font-weight:bold;")
|
||||
return lbl
|
||||
|
||||
def _divider(self) -> QFrame:
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.Shape.HLine)
|
||||
line.setStyleSheet("color: rgba(128,128,128,40);")
|
||||
return line
|
||||
|
||||
def _on_nav(self, index: int):
|
||||
self.stack.setCurrentIndex(index)
|
||||
|
||||
def _apply_theme(self):
|
||||
t = theme.current()
|
||||
is_dark = theme.name() == "dark"
|
||||
# 把 rgba(r,g,b,a) 转成 rgb(r,g,b)
|
||||
panel_bg = t["panel_bg"]
|
||||
if panel_bg.startswith("rgba"):
|
||||
tmp = panel_bg.replace("rgba", "rgb")
|
||||
# "rgb(r,g,b,a)" -> 取前三项 "rgb(r,g,b"
|
||||
head = tmp.rsplit(",", 1)[0]
|
||||
panel_rgb = head + ")"
|
||||
else:
|
||||
panel_rgb = panel_bg
|
||||
self.setStyleSheet(f"""
|
||||
QDialog, QWidget {{
|
||||
background: {panel_rgb};
|
||||
color: {t['search_color']};
|
||||
}}
|
||||
QListWidget {{
|
||||
background: {'rgba(0,0,0,30)' if is_dark else 'rgba(0,0,0,6)'};
|
||||
}}
|
||||
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;
|
||||
}}
|
||||
QCheckBox {{ font-size:13px; spacing:8px; }}
|
||||
QCheckBox::indicator {{
|
||||
width:18px; height:18px; border-radius:4px;
|
||||
border:1px solid {t['search_border']};
|
||||
background:{t['search_bg']};
|
||||
}}
|
||||
QCheckBox::indicator:checked {{
|
||||
background:#4a9eff; border-color:#4a9eff;
|
||||
image: url(none);
|
||||
}}
|
||||
QSpinBox {{
|
||||
background:{t['search_bg']}; color:{t['search_color']};
|
||||
border:1px solid {t['search_border']}; border-radius:5px;
|
||||
padding:4px 8px; font-size:12px;
|
||||
}}
|
||||
QPushButton {{
|
||||
background:{t['search_bg']}; color:{t['search_color']};
|
||||
border:1px solid {t['search_border']}; border-radius:6px;
|
||||
padding:6px 14px; font-size:12px;
|
||||
}}
|
||||
QPushButton:hover {{ border-color:#4a9eff; color:#4a9eff; }}
|
||||
""")
|
||||
# 导航背景
|
||||
nav_bg = "rgba(0,0,0,30)" if is_dark else "rgba(0,0,0,6)"
|
||||
self.nav.setStyleSheet(f"""
|
||||
QListWidget {{ border:none; outline:none; padding:8px 4px;
|
||||
background:{nav_bg}; }}
|
||||
QListWidget::item {{ height:36px; border-radius:6px;
|
||||
padding-left:10px; font-size:13px;
|
||||
color:{t['search_color']}; }}
|
||||
QListWidget::item:selected {{ background:#4a9eff; color:white; }}
|
||||
QListWidget::item:hover:!selected {{
|
||||
background:{'rgba(255,255,255,10)' if is_dark else 'rgba(0,0,0,6)'}; }}
|
||||
""")
|
||||
self._update_theme_cards()
|
||||
|
||||
# 捐赠卡片样式
|
||||
if hasattr(self, "_donate_card"):
|
||||
card_bg = "rgba(0,0,0,25)" if is_dark else "rgba(255,255,255,10)"
|
||||
self._donate_card.setStyleSheet(f"""
|
||||
QFrame#donate_card {{
|
||||
background:{card_bg};
|
||||
border: 1px solid {t['panel_border']};
|
||||
border-radius:14px;
|
||||
}}
|
||||
""")
|
||||
64
ui/theme.py
Normal file
@ -0,0 +1,64 @@
|
||||
from db.database import get_setting, set_setting
|
||||
|
||||
THEMES = {
|
||||
"dark": {
|
||||
"panel_bg": "rgba(28,28,28,242)",
|
||||
"panel_border": "rgba(255,255,255,18)",
|
||||
"header_bg": "#3a3a3a",
|
||||
"header_hover": "#484848",
|
||||
"item_name_color":"#ddd",
|
||||
"search_bg": "rgba(255,255,255,12)",
|
||||
"search_border": "#555",
|
||||
"search_color": "#eee",
|
||||
"search_focus": "#4a9eff",
|
||||
"btn_color": "#999",
|
||||
"btn_hover": "#fff",
|
||||
"scrollbar": "#555",
|
||||
"menu_bg": "#2b2b2b",
|
||||
"menu_color": "#eee",
|
||||
"menu_border": "#555",
|
||||
"menu_selected": "#3a3a3a",
|
||||
},
|
||||
"light": {
|
||||
"panel_bg": "rgba(245,245,245,250)",
|
||||
"panel_border": "rgba(0,0,0,15)",
|
||||
"header_bg": "#e0e0e0",
|
||||
"header_hover": "#d0d0d0",
|
||||
"item_name_color":"#222",
|
||||
"search_bg": "rgba(0,0,0,8)",
|
||||
"search_border": "#bbb",
|
||||
"search_color": "#222",
|
||||
"search_focus": "#4a9eff",
|
||||
"btn_color": "#555",
|
||||
"btn_hover": "#000",
|
||||
"scrollbar": "#bbb",
|
||||
"menu_bg": "#f5f5f5",
|
||||
"menu_color": "#222",
|
||||
"menu_border": "#ccc",
|
||||
"menu_selected": "#e0e0e0",
|
||||
},
|
||||
}
|
||||
|
||||
_current = "dark"
|
||||
|
||||
|
||||
def load():
|
||||
"""从数据库读取上次保存的主题,应在 init_db() 之后调用"""
|
||||
global _current
|
||||
saved = get_setting("theme", "dark")
|
||||
_current = saved if saved in THEMES else "dark"
|
||||
|
||||
|
||||
def current() -> dict:
|
||||
return THEMES[_current]
|
||||
|
||||
|
||||
def name() -> str:
|
||||
return _current
|
||||
|
||||
|
||||
def set_theme(theme_name: str):
|
||||
global _current
|
||||
if theme_name in THEMES:
|
||||
_current = theme_name
|
||||
set_setting("theme", theme_name)
|
||||