Files
niumasoftware/ui/box.py
T
2026-10-12 14:57:46 +08:00

955 lines
47 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.
# -*- coding: utf-8 -*-
"""桌面文件框:可拖动、可缩放、可吸附、可调透明度、可显示文件夹内容的独立桌面窗口。"""
import os
import sys
import subprocess
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QMenu, QListView,
QMessageBox, QApplication, QSizePolicy, QSlider, QSpinBox, QDialog,
QDialogButtonBox, QFormLayout, QAbstractItemView, QFileDialog,
)
from PyQt6.QtCore import (
Qt, QPoint, QRect, QRectF, QSize, QPropertyAnimation, QEasingCurve, QTimer,
QDir, QModelIndex, QUrl,
)
from PyQt6.QtGui import (
QPainter, QColor, QPen, QBrush, QIcon, QPixmap, QRegion, QPainterPath,
QFileSystemModel, QStandardItemModel, QStandardItem,
)
import qtawesome as qta
from db import database
from ui.item import extract_icon
import ui.theme as theme
import ui.dialog_style as dialog_style
import shortcut_target
# ── 文件夹模式:双击条目打开文件/进入子目录 ────────────
class FolderListView(QListView):
"""显示文件夹内容的列表视图(文件夹浏览模式用)。"""
def __init__(self, model: QFileSystemModel, root_path: str, parent=None):
super().__init__(parent)
self.setModel(model)
self.setRootIndex(model.index(root_path))
self.setViewMode(QListView.ViewMode.IconMode)
self.setResizeMode(QListView.ResizeMode.Adjust)
self.setMovement(QListView.Movement.Static)
self.setUniformItemSizes(True)
self.setIconSize(QSize(40, 40))
self.setGridSize(QSize(72, 88))
self.setSpacing(4)
self.setWordWrap(True)
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.doubleClicked.connect(self._on_double_clicked)
# customContextMenuPolicy 由 FileBox._install_drag_filter_recursive 统一设置
self.setStyleSheet(
"""
QListView { background:transparent; color:#ddd; border:none;
font-size:11px; outline:none; }
QListView::item { padding:4px; border-radius:4px; }
QListView::item:hover { background:rgba(74,158,255,30); }
QListView::item:selected { background:rgba(74,158,255,50); }
"""
)
def _on_double_clicked(self, index: QModelIndex):
model = self.model()
path = model.filePath(index)
if not path or not os.path.exists(path): return
if os.path.isdir(path):
self.setRootIndex(index)
else:
try: os.startfile(path)
except Exception:
try: subprocess.Popen([path], creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
except Exception as e: dialog_style.warning(self, "打开失败", str(e))
_MENU_QSS = """
QMenu { background:#2b2b2b; color:#ddd; border:1px solid #444; padding:4px; border-radius:6px; }
QMenu::item { padding:6px 16px; border-radius:3px; }
QMenu::item:selected { background:#3a5a8a; }
"""
# ── FileBox 窗口 ───────────────────────────────────────
# ── 全局圆角常量(QSS 必须一致) ──────────────────────────
# 8px 与 Win11 DWMWCP_ROUND 的系统裁剪半径贴合:
# QSS 半径 > DWM 半径时(如 12px),圆弧外 8~12px 环带会露出方形亚克力底
RADIUS = 8
class FileBox(QWidget):
"""桌面文件框:两种模式 —— 拖入卡片 / 文件夹浏览。"""
MIN_W = 180
MIN_H = 200
_DRAG_MARGIN = 8
_SNAP_THRESHOLD = 15
_BOX_GAP = 2
_instances: list["FileBox"] = []
_panel_ref = None
def __init__(self, box_data: dict):
super().__init__()
self._box_data = box_data
self._box_id = box_data["id"]
self._collapsed = bool(box_data.get("collapsed", 0))
self._opacity = float(box_data.get("opacity", 0.5))
self._folder_path = (box_data.get("folder_path") or "").strip()
self._group_id = box_data.get("group_id") # None 或 int
# 模式优先级:group_id > folder_path > 拖入
self._is_group_linked = self._group_id is not None
self._is_folder_mode = (not self._is_group_linked) and bool(self._folder_path)
self._view_mode = (box_data.get("view_mode") or "icon").strip()
if self._view_mode not in ("icon", "list"):
self._view_mode = "icon"
self._drag_pos: QPoint | None = None
self._resize_edge: str | None = None
self._resize_start_global: QPoint | None = None
self._resize_start_geo: QRect | None = None
self._save_timer = QTimer(self)
self._save_timer.setSingleShot(True)
self._save_timer.setInterval(500)
self._save_timer.timeout.connect(self._save_all)
# ⚠️ 顺序关键:先设 flags,再设透明属性
# setWindowFlags() 会重建原生窗口并丢掉 WA_TranslucentBackground,
# 属性若设在 flags 之前 = 透明失效 = QSS 圆角画在不透明方形窗口上(直角根因)
self._apply_flags()
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setAcceptDrops(True)
self.setMouseTracking(True)
w = max(self.MIN_W, int(box_data.get("w", 240)))
h = max(self.MIN_H, int(box_data.get("h", 320)))
self.setGeometry(int(box_data.get("x", 100)), int(box_data.get("y", 100)), w, h)
self._build_ui()
self._apply_theme()
# 窗口本体强制透明(防止 Qt/palette 画默认背景色透出来)
self.setStyleSheet("FileBox { background: transparent; }")
# Windows 11: 让 DWM 在 OS 层强制圆角(物理裁剪,不依赖 QSS/半透明)
self._apply_win11_round_corners()
# 圆角和透明度现在完全靠 QSS(跟 dock.py 同款 container 方案),
# 不再需要 setWindowOpacity / _update_mask / paintEvent 自绘
FileBox._instances.append(self)
if self._is_folder_mode:
self._setup_folder_model()
if self._collapsed:
self._collapse_content()
# ── Flags ──────────────────────────────────────────────
def _apply_flags(self):
# Tool:不出现在任务栏、不在 Alt+Tab 切换列表里
# FramelessWindowHint:无边框
# NoDropShadowWindowHint 不加——它会杀死 layered window 路径 → WA_TranslucentBackground 失效
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Tool)
def _apply_win11_round_corners(self):
"""Windows 11: DWMWA_WINDOW_CORNER_PREFERENCE(33) = DWMWCP_ROUND(2)。
让 DWM 在 OS 层物理裁剪窗口四角 —— 无边框工具窗口默认是直角,
这是唯一 100% 生效的圆角方案(dock 主面板靠 Win32 合成 API 也拿到了同样效果)。
Windows 10 无此 API,静默失败,由 QSS border-radius + 半透明兜底。"""
if sys.platform != "win32":
return
try:
import ctypes
hwnd = int(self.winId()) # 强制创建原生窗口
pref = ctypes.c_int(2) # DWMWCP_ROUND
ctypes.windll.dwmapi.DwmSetWindowAttribute(
hwnd, 33, ctypes.byref(pref), ctypes.sizeof(pref)
)
except Exception:
pass
def resizeEvent(self, event):
super().resizeEvent(event)
def showEvent(self, event):
super().showEvent(event)
# 保险:窗口若被重建(flags 变更/系统回收),透明属性、DWM 圆角、
# Win32 磨砂合成全部会丢,每次 show 重新应用(幂等)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
self._apply_win11_round_corners()
self._apply_win32_blur(theme.name() == "dark")
# ── UI 构建 ────────────────────────────────────────────
def _build_ui(self):
# FileBox 自身:透明背景(靠 WA_TranslucentBackground)
# 里面 container 用 QSS 画圆角 + 背景 + border(跟 dock.py 同款)
outer = QVBoxLayout(self)
outer.setContentsMargins(1, 1, 1, 1); outer.setSpacing(0)
self._container = QWidget()
self._container.setObjectName("container")
root = QVBoxLayout(self._container)
root.setContentsMargins(0, 0, 0, 0); root.setSpacing(0)
outer.addWidget(self._container)
self._title_bar = QWidget()
self._title_bar.setObjectName("title_bar")
self._title_bar.setFixedHeight(28); self._title_bar.setAcceptDrops(True)
h = QHBoxLayout(self._title_bar); h.setContentsMargins(10, 0, 6, 0); h.setSpacing(2)
self._title_label = QLabel(self._box_data.get("title", "文件框"))
self._title_label.setCursor(Qt.CursorShape.PointingHandCursor)
self._title_label.setAcceptDrops(True)
self._title_label.mouseDoubleClickEvent = self._rename_box_title
h.addWidget(self._title_label); h.addStretch()
if self._is_folder_mode:
h.addWidget(self._mk_title_btn("fa5s.folder-open", "选择文件夹", self._choose_folder))
h.addWidget(self._mk_title_btn("fa5s.times", "切换为拖入模式", self._convert_to_drag_mode))
else:
h.addWidget(self._mk_title_btn("fa5s.pen", "重命名", self._rename_box_title))
# 视图切换按钮(只在拖入模式显示)
self._view_btn = self._mk_title_btn(
"fa5s.th-large" if self._view_mode == "list" else "fa5s.th",
"切换列表/图标视图", self._toggle_view_mode,
)
h.addWidget(self._view_btn)
h.addWidget(self._mk_title_btn("fa5s.adjust", "透明度(所有框)", self._open_opacity_dialog))
h.addWidget(self._mk_title_btn("fa5s.minus", "最小化", self._toggle_collapse))
self._close_btn = QPushButton()
self._close_btn.setFixedSize(22, 22); self._close_btn.setAcceptDrops(True)
self._close_btn.setStyleSheet(
"QPushButton { border:none; background:transparent; border-radius:4px; }"
"QPushButton:hover { background:#e55; color:white; }"
)
self._close_btn.setIcon(qta.icon("fa5s.times", color="#aaa"))
self._close_btn.setIconSize(QSize(11, 11)); self._close_btn.setToolTip("关闭")
self._close_btn.clicked.connect(self._close_box)
h.addWidget(self._close_btn)
root.addWidget(self._title_bar)
self._content = QWidget(); self._content.setAcceptDrops(True)
self._content.setStyleSheet("QWidget { background: transparent; }")
self._content_layout = QVBoxLayout(self._content)
self._content_layout.setContentsMargins(6, 4, 6, 6); self._content_layout.setSpacing(4)
if self._is_folder_mode:
self._build_folder_content()
else:
self._build_drag_content()
root.addWidget(self._content, 1)
self._install_drag_filter_recursive()
# ── 拖入卡片内容(QListView + QStandardItemModel) ──────
def _build_drag_content(self):
"""拖入卡片模式:用 QListView + QStandardItemModel,支持图标/列表两种视图。"""
self._item_model = QStandardItemModel(self)
self._item_list = QListView(self._content)
self._item_list.setModel(self._item_model)
# customContextMenuPolicy 由 FileBox._install_drag_filter_recursive 统一设置
self._item_list.doubleClicked.connect(self._on_item_double_clicked)
self._item_list.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self._item_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) # ⚠️ 必须 ExtendedSelection 才支持拖框选
self._item_list.setDragEnabled(False) # 关掉内部拖(防止和外部文件拖放冲突)
self._item_list.setAcceptDrops(True)
self._item_list.setStyleSheet(
"""
QListView { background:transparent; color:#ddd; border:none;
font-size:11px; outline:none; }
QListView::item { padding:3px; border-radius:4px; }
QListView::item:hover { background:rgba(74,158,255,30); }
QListView::item:selected { background:rgba(74,158,255,50); }
"""
)
self._apply_view_mode()
self._content_layout.addWidget(self._item_list)
# 空提示
self._empty_label = QLabel("拖文件进来")
self._empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._empty_label.setStyleSheet("color:#555; font-size:11px; background:transparent;")
self._content_layout.addWidget(self._empty_label)
self.refresh_items()
def _apply_view_mode(self):
"""根据 self._view_mode 切换 QListView 显示方式。"""
if self._view_mode == "list":
self._item_list.setViewMode(QListView.ViewMode.ListMode)
self._item_list.setIconSize(QSize(16, 16))
self._item_list.setGridSize(QSize())
self._item_list.setMovement(QListView.Movement.Static)
self._item_list.setResizeMode(QListView.ResizeMode.Adjust)
self._item_list.setSpacing(2)
self._item_list.setWordWrap(False)
else:
self._item_list.setViewMode(QListView.ViewMode.IconMode)
self._item_list.setIconSize(QSize(28, 28))
self._item_list.setGridSize(QSize(72, 88))
self._item_list.setMovement(QListView.Movement.Static)
self._item_list.setResizeMode(QListView.ResizeMode.Adjust)
self._item_list.setSpacing(4)
self._item_list.setWordWrap(True)
def _toggle_view_mode(self):
self._view_mode = "list" if self._view_mode == "icon" else "icon"
self._apply_view_mode()
# 换按钮图标
icon_name = "fa5s.th-large" if self._view_mode == "list" else "fa5s.th"
self._view_btn.setIcon(qta.icon(icon_name, color="#888"))
self._save_timer.start()
# ── 文件夹浏览内容 ──────────────────────────────────────
def _build_folder_content(self):
self._fs_model = QFileSystemModel(self)
self._fs_model.setFilter(QDir.Filter.AllDirs | QDir.Filter.Files | QDir.Filter.NoDotAndDotDot)
self._fs_model.setReadOnly(True)
self._list_view = FolderListView(self._fs_model, self._folder_path, self._content)
self._content_layout.addWidget(self._list_view)
self._empty_label = QLabel("文件夹不存在")
self._empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._empty_label.setStyleSheet("color:#555; font-size:11px; background:transparent;")
if not os.path.isdir(self._folder_path):
self._list_view.hide(); self._empty_label.show()
self._content_layout.addWidget(self._empty_label)
def _setup_folder_model(self): pass
def _refresh_folder(self):
if not self._is_folder_mode or not os.path.isdir(self._folder_path): return
self._fs_model.setRootPath(self._folder_path)
self._list_view.setRootIndex(self._fs_model.index(self._folder_path))
def _convert_to_drag_mode(self):
ret = dialog_style.question(self, "确认", "切换为拖入模式?(文件夹路径会被清除)", default_button=QMessageBox.StandardButton.No)
if ret != QMessageBox.StandardButton.Yes: return
database.update_file_box(self._box_id, folder_path="")
self._folder_path = ""; self._is_folder_mode = False
while self._content_layout.count():
item = self._content_layout.takeAt(0)
w = item.widget()
if w: w.setParent(None); w.deleteLater()
self._build_drag_content()
self._install_drag_filter_recursive()
# 重建标题栏按钮
while self._title_bar.layout().count():
item = self._title_bar.layout().takeAt(0)
w = item.widget()
if w: w.setParent(None); w.deleteLater()
self._build_title_bar_after_convert()
def _build_title_bar_after_convert(self):
h = self._title_bar.layout()
self._title_label.setParent(None)
# 重新构建:label + stretch + rename + view + opacity + minimize + close
h.addWidget(self._title_label); h.addStretch()
h.addWidget(self._mk_title_btn("fa5s.pen", "重命名", self._rename_box_title))
self._view_btn = self._mk_title_btn(
"fa5s.th-large" if self._view_mode == "list" else "fa5s.th",
"切换列表/图标视图", self._toggle_view_mode,
)
h.addWidget(self._view_btn)
h.addWidget(self._mk_title_btn("fa5s.adjust", "透明度(所有框)", self._open_opacity_dialog))
h.addWidget(self._mk_title_btn("fa5s.minus", "最小化", self._toggle_collapse))
h.addWidget(self._close_btn)
def _choose_folder(self):
path = QFileDialog.getExistingDirectory(self, "选择文件夹", self._folder_path or os.path.expanduser("~"))
if not path: return
self._folder_path = path
self._box_data["title"] = os.path.basename(path) or path
self._title_label.setText(self._box_data["title"])
database.update_file_box(self._box_id, folder_path=path, title=self._box_data["title"])
self._refresh_folder()
def _mk_title_btn(self, icon_name, tip, callback):
btn = QPushButton(); btn.setFixedSize(22, 22); btn.setAcceptDrops(True)
btn.setStyleSheet(
"QPushButton { border:none; background:transparent; border-radius:4px; }"
"QPushButton:hover { background:rgba(255,255,255,30); }"
)
btn.setIcon(qta.icon(icon_name, color="#888"))
btn.setIconSize(QSize(11, 11)); btn.setToolTip(tip)
btn.clicked.connect(callback)
return btn
# ── FileBox 右键菜单(CustomContextMenu 信号 —— 覆盖所有子控件) ──
def _install_drag_filter_recursive(self):
# 统一用 CustomContextMenu 策略:Qt 会调用 customContextMenuRequested(global_pos)
# 对 FileBox 和所有子控件设 CustomContextMenuPolicy,然后统一转发到 FileBox._on_box_context_menu
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self._on_box_context_menu)
for child in self.findChildren(QWidget):
child.setAcceptDrops(True)
child.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
# lambda 默认参数 trick 绑定当前 child(避免循环变量 late-binding bug)
child.customContextMenuRequested.connect(
lambda pos, c=child: self.customContextMenuRequested.emit(c.mapToGlobal(pos))
)
# 顶层容器装拖放 filter(只拦 DragEnter/Drop,不拦鼠标)
for w in (self._content, self._title_bar):
if w: w.installEventFilter(self)
self.installEventFilter(self)
# ── 主题 ────────────────────────────────────────────
@staticmethod
def _with_opacity(rgba_str: str, opacity: float) -> str:
"""给 rgba(...) 字符串替换 alpha 值。opacity 是 0~1 的浮点数。
如果输入是 #rrggbb 也先转换成 rgba(r,g,b,a)。"""
import re
s = rgba_str.strip().lower()
nums = [int(x) for x in re.findall(r"\d+", s)]
if len(nums) >= 3:
a = max(0, min(255, int(opacity * 255)))
return f"rgba({nums[0]},{nums[1]},{nums[2]},{a})"
return rgba_str
def _apply_theme(self):
t = theme.current(); is_dark = theme.name() == "dark"
# ── container:圆角 + 半透明背景 + border(跟 dock.py 同款) ──
# panel_bg 的 alpha 由 _opacity 控制(覆盖 theme 自带 alpha)
panel_bg_rgba = self._with_opacity(t.get("panel_bg", "#1e1e1e"), self._opacity)
# panel_border 保留 theme 自带 alpha(装饰线,不二次乘)
panel_border_rgba = t.get("panel_border", "#888")
self._container.setStyleSheet(
f"""
QWidget#container {{
background: {panel_bg_rgba};
border: 1px solid {panel_border_rgba};
border-radius: {RADIUS}px;
}}
"""
)
# ── title_bar:比 container 稍深/稍浅,顶部圆角对齐 ──
# 自己造 header 颜色(不用 theme.header_bg,它是叠加色语义怪)
if is_dark:
header_rgb = (50, 50, 50) # 比 dark panel_bg 稍亮
else:
header_rgb = (230, 230, 230) # 比 light panel_bg 稍深
header_a = max(0, min(255, int(self._opacity * 255)))
title_fg = t.get("search_color", "#ddd")
self._title_bar.setStyleSheet(
f"""
QWidget#title_bar {{
background: rgba({header_rgb[0]},{header_rgb[1]},{header_rgb[2]},{header_a});
border-top-left-radius: {RADIUS}px;
border-top-right-radius: {RADIUS}px;
}}
"""
)
self._title_label.setStyleSheet(
f"color:{title_fg}; font-size:12px; font-weight:bold; background:transparent;"
)
# item 卡片文字色跟随主题
if hasattr(self, "_item_list"):
item_fg = t.get("item_name_color", "#ddd")
self._item_list.setStyleSheet(
f"""
QListView {{ background:transparent; color:{item_fg}; border:none;
font-size:11px; outline:none; }}
QListView::item {{ padding:3px; border-radius:4px; }}
QListView::item:hover {{ background:rgba(74,158,255,30); }}
QListView::item:selected {{ background:rgba(74,158,255,50); }}
"""
)
# ── 关键:Win32 磨砂合成(跟 dock.py 完全同款) ──
# dock 的半透明+圆角从来不靠 Qt 的 WA_TranslucentBackground,
# 而是靠这个 accent API:DWM 把窗口变成磨砂材质 + Win11 自动圆角。
# 没有它,FileBox 就是黑色不透明直角窗口(Qt 透明路径在本机不生效)。
self._apply_win32_blur(is_dark)
def _apply_win32_blur(self, is_dark: bool):
"""给窗口应用 Win32 磨砂合成(ACCENT_ENABLE_ACRYLICBLURBEHIND)。
复刻 dock.py 的做法:窗口获得 DWM 半透明磨砂材质,Win11 自动圆角。"""
if sys.platform != "win32":
return
try:
import ctypes
from ctypes import windll, sizeof, byref, c_int, Structure
class ACCENT_POLICY(Structure):
_fields_ = [
("AccentState", c_int),
("AccentFlags", c_int),
("GradientColor", c_int),
("AnimationId", c_int),
]
class WINDOWCOMPOSITIONATTRIBDATA(Structure):
_fields_ = [
("Attribute", c_int),
("Data", ctypes.c_void_p),
("SizeOfData", c_int),
]
hwnd = int(self.winId())
accent = ACCENT_POLICY()
accent.AccentState = 4 # ACCENT_ENABLE_ACRYLICBLURBEHIND
# 磨砂底色:主题色 + 固定 alpha(跟 dock 一致用 0x90);
# 精细透明度仍由 QSS container 层的 _opacity 控制(两层叠加)
if is_dark:
accent.GradientColor = 0x901E1E1E # ABGR: 56% + RGB(30,30,30)
else:
accent.GradientColor = 0x90FFFFFF # ABGR: 56% + RGB(255,255,255)
accent.AccentFlags = 2
data = WINDOWCOMPOSITIONATTRIBDATA()
data.Attribute = 19 # WCA_ACCENT_POLICY
data.Data = ctypes.cast(byref(accent), ctypes.c_void_p)
data.SizeOfData = sizeof(accent)
windll.user32.SetWindowCompositionAttribute(hwnd, byref(data))
except Exception:
pass
# ── 事件过滤器 ──────────────────────────────────────
# 只拦两类事件:1) 拖放(所有子控件);2) 边缘 resize(仅 FileBox 自身)
# 中间区域的鼠标事件 → 完全放行给 QListView 处理框选/选择
def eventFilter(self, obj, event):
et = event.type()
# ✅ 所有子控件的拖放事件 → 统一转给 FileBox
if et == event.Type.DragEnter: self.dragEnterEvent(event); return True
if et == event.Type.DragMove: self.dragMoveEvent(event); return True
if et == event.Type.Drop: self.dropEvent(event); return True
# ✅ FileBox 自身边缘区域的鼠标 → resize / 全局 cursor
if obj is self:
if et == event.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton:
local = event.pos()
edge = self._edge_at(local)
if edge or self._resize_edge:
self.mousePressEvent(event); return True
if et == event.Type.MouseMove:
if self._resize_edge:
self.mouseMoveEvent(event); return True
edge = self._edge_at(event.pos())
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,
}
self.setCursor(CUR.get(edge, Qt.CursorShape.ArrowCursor))
if et == event.Type.MouseButtonRelease and self._resize_edge:
self.mouseReleaseEvent(event); return True
# ❌ 子控件的所有鼠标事件 → 放行!QListView 需要处理框选/选择
# ❌ FileBox 中间区域的鼠标 → 放行!(标题栏/子控件自己处理)
return super().eventFilter(obj, event)
def _handle_drop(self, event):
if self._is_folder_mode:
target = self._folder_path
added = False
for url in event.mimeData().urls():
src = url.toLocalFile()
if not src: continue
try:
dest = os.path.join(target, os.path.basename(src))
os.rename(src, dest); added = True
except Exception:
try:
import shutil; shutil.move(src, dest); added = True
except Exception: pass
if added: self._refresh_folder()
else:
added = False
for url in event.mimeData().urls():
path = url.toLocalFile()
if not path: continue
store = shortcut_target.path_for_storage(path)
name = shortcut_target.item_name_from_sources(path, store)
if self._is_group_linked:
database.add_item(self._group_id, name, store); added = True
else:
database.add_file_box_item(self._box_id, name, store); added = True
if added:
self.refresh_items()
self._notify_panel_refresh()
event.acceptProposedAction()
def _notify_panel_refresh(self):
"""通知主程序刷新分组(分组联动模式下 FileBox 改动需要同步到主面板)。"""
if FileBox._panel_ref and hasattr(FileBox._panel_ref, "refresh_groups"):
try: FileBox._panel_ref.refresh_groups()
except Exception: pass
# ── 拖入 / 分组模式:刷新卡片到 QStandardItemModel ───────
def refresh_items(self):
if self._is_folder_mode or not hasattr(self, "_item_model"): return
if self._is_group_linked:
items = database.get_items(self._group_id)
else:
items = database.get_file_box_items(self._box_id)
self._item_model.clear()
for it in items:
icon = extract_icon(it["path"])
item = QStandardItem(icon, it["name"])
item.setData(it["path"], Qt.ItemDataRole.UserRole)
item.setData(it["id"], Qt.ItemDataRole.UserRole + 1)
self._item_model.appendRow(item)
count = len(items)
self._empty_label.setVisible(count == 0)
self._item_list.setVisible(count > 0)
def _open_item(self, idx: QModelIndex):
"""打开 QListView 里的某个 item(双击 or 右键)。"""
path = idx.data(Qt.ItemDataRole.UserRole)
if not path or not os.path.exists(path):
dialog_style.warning(self, "打不开", f"文件不存在:\n{path}"); return
try: os.startfile(path)
except Exception:
try: subprocess.Popen([path], creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
except Exception as e: dialog_style.warning(self, "启动失败", str(e))
def _on_item_double_clicked(self, idx: QModelIndex):
self._open_item(idx)
def _toggle_collapse(self):
self._collapsed = not self._collapsed
if self._collapsed: self._content.hide(); self.setFixedHeight(36)
else:
self._content.show(); self.setMinimumHeight(self.MIN_H); self.setMaximumHeight(16777215)
self.resize(self.width(), max(self.MIN_H, int(self._box_data.get("h", 320))))
self._save_timer.start()
def _rename_box_title(self, _event=None):
title, ok = dialog_style.get_text(self, "重命名文件框", "新标题:", text=self._box_data.get("title", "文件框"))
if not ok or not title.strip(): return
title = title.strip(); self._box_data["title"] = title; self._title_label.setText(title)
database.update_file_box(self._box_id, title=title)
# ── 透明度(所有框联动) ──────────────────────────────
def _open_opacity_dialog(self, _event=None):
all_boxes = [b for b in FileBox._instances if b.isVisible()]
if not all_boxes: return
dlg = QDialog(self); dlg.setWindowTitle("所有文件框透明度")
dlg.setStyleSheet(
"""
QDialog { background:#2b2b2b; color:#ddd; border:1px solid #444; }
QLabel { color:#ddd; }
QSlider::groove:horizontal { height:6px; background:#444; border-radius:3px; }
QSlider::handle:horizontal { background:#4a9eff; width:14px; height:14px; margin:-4px 0; border-radius:7px; }
QSpinBox { background:#3a3a3a; color:#ddd; border:1px solid #555; border-radius:4px; padding:3px; width:60px; }
QPushButton { background:#3a3a3a; color:#ddd; border:1px solid #555; border-radius:4px; padding:5px 16px; }
QPushButton:hover { background:#4a4a4a; }
"""
)
form = QFormLayout(dlg)
slider = QSlider(Qt.Orientation.Horizontal); slider.setRange(20, 100); slider.setValue(int(self._opacity * 100))
spin = QSpinBox(); spin.setRange(20, 100); spin.setSuffix(" %"); spin.setValue(int(self._opacity * 100))
def apply(v: int):
"""改 _opacity + 重设 QSS(透明度现在在 QSS rgba 里)。"""
for b in FileBox._instances:
b._opacity = v / 100.0
b._apply_theme()
slider.valueChanged.connect(lambda v: (spin.setValue(v), apply(v)))
spin.valueChanged.connect(lambda v: (slider.setValue(v), apply(v)))
form.addRow("背景不透明度:", slider); form.addRow("数值:", spin)
hint = QLabel("只影响文件框背景色,标题和图标始终清晰"); hint.setStyleSheet("color:#888; font-size:10px;")
form.addRow("", hint)
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
btns.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
btns.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
btns.accepted.connect(dlg.accept); btns.rejected.connect(dlg.reject); form.addRow(btns)
original = {b._box_id: b._opacity for b in FileBox._instances}
if dlg.exec() == QDialog.DialogCode.Accepted:
opacity = spin.value() / 100.0
for b in FileBox._instances:
b._opacity = opacity
database.update_file_box(b._box_id, opacity=opacity)
else:
for b in FileBox._instances:
b._opacity = original.get(b._box_id, 1.0)
b._apply_theme()
def _close_box(self):
ret = dialog_style.question(
self, "确认",
f"关闭文件框「{self._box_data.get('title', '')}」?\n文件框会保存,下次启动仍然显示。",
default_button=QMessageBox.StandardButton.No,
)
if ret != QMessageBox.StandardButton.Yes: return
database.update_file_box(self._box_id, collapsed=0); self.close()
def _delete_box(self):
"""彻底删除文件框:DB 里删掉所有记录 + 从 _instances 移除 + 关闭窗口。"""
ret = dialog_style.question(
self, "确认删除",
f"确定删除文件框「{self._box_data.get('title', '')}」?\n⚠️ 不可恢复,里面的条目也会一并删除。",
default_button=QMessageBox.StandardButton.No,
)
if ret != QMessageBox.StandardButton.Yes: return
database.delete_file_box(self._box_id)
if self in FileBox._instances: FileBox._instances.remove(self)
self.close() # 触发 closeEvent → _save_all + remove from instances(重复 remove 安全)
# ── 鼠标事件 ──────────────────────────────────────────
def _edge_at(self, local: QPoint) -> str | None:
x, y = local.x(), local.y(); w, h = self.width(), self.height(); m = self._DRAG_MARGIN
l, r, t, b = x <= m, x >= w - m, y <= m, y >= h - m
if t and l: return "tl"
if t and r: return "tr"
if b and l: return "bl"
if b and r: return "br"
if t: return "t"
if b: return "b"
if l: return "l"
if r: return "r"
return None
def mousePressEvent(self, event):
if event.button() != Qt.MouseButton.LeftButton: return super().mousePressEvent(event)
local = event.pos()
if self._title_bar.geometry().contains(local):
self._drag_pos = event.globalPosition().toPoint() - self.frameGeometry().topLeft(); return
edge = self._edge_at(local)
if edge:
self._resize_edge = edge
self._resize_start_global = event.globalPosition().toPoint()
self._resize_start_geo = QRect(self.geometry()); return
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
gp = event.globalPosition().toPoint()
if self._resize_edge and event.buttons() & Qt.MouseButton.LeftButton: self._do_resize(gp); return
if self._drag_pos and event.buttons() & Qt.MouseButton.LeftButton: self.move(gp - self._drag_pos); return
edge = self._edge_at(event.pos())
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,
}
self.setCursor(CUR.get(edge, Qt.CursorShape.ArrowCursor))
def mouseReleaseEvent(self, event):
self._drag_pos = None; self._resize_edge = None
self._resize_start_global = None; self._resize_start_geo = None
self._save_timer.start(); self._do_snap()
super().mouseReleaseEvent(event)
# ── FileBox 右键菜单(统一入口:所有子控件都转发到这里) ──
def _on_box_context_menu(self, global_pos):
"""global_pos 是屏幕坐标。根据点到的位置决定弹哪个菜单:
- QListView item 上 → item 级菜单(打开/重命名/删除)
- 空白处 → FileBox 级菜单(刷新/新增/关闭)
"""
# 先检测:点在哪个 QListView 上?
for lv, is_folder_mode_lv in (
(getattr(self, "_item_list", None), False),
(getattr(self, "_list_view", None), True),
):
if lv is None: continue
local_lv = lv.mapFromGlobal(global_pos)
if not lv.rect().contains(local_lv): continue
# 在 QListView 上 → 判断是 item 还是空白
idx = lv.indexAt(local_lv)
if idx.isValid():
# ✅ item 上右键 → 弹 item 级菜单
if is_folder_mode_lv:
self._show_folder_lv_item_menu(lv, idx, global_pos)
else:
self._show_item_list_menu(idx, global_pos)
return
# QListView 空白处也弹 FileBox 菜单(让用户方便新增)
break # 跳出循环,继续往下弹 FileBox 菜单
# ✅ 标题栏按钮 / 标题栏空白 / FileBox 背景 / QListView 空白
menu = QMenu(self); menu.setStyleSheet(_MENU_QSS)
if self._is_folder_mode:
menu.addAction(qta.icon("fa5s.sync", color="#4a9eff"), "刷新").triggered.connect(self._refresh_folder)
menu.addAction(qta.icon("fa5s.folder-open", color="#aaa"), "选择文件夹").triggered.connect(self._choose_folder)
else:
menu.addAction(qta.icon("fa5s.sync", color="#4a9eff"), "刷新").triggered.connect(self.refresh_items)
menu.addAction(qta.icon("fa5s.file", color="#aaa"), "新增文件...").triggered.connect(self._pick_files)
menu.addAction(qta.icon("fa5s.folder", color="#aaa"), "新增文件夹...").triggered.connect(self._pick_folders)
menu.addSeparator()
menu.addAction(qta.icon("fa5s.plus-square", color="#aaa"), "新建文件夹").triggered.connect(self._create_new_folder_in_box)
menu.addSeparator()
menu.addAction(qta.icon("fa5s.pen", color="#aaa"), "重命名文件框").triggered.connect(self._rename_box_title)
menu.addSeparator()
menu.addAction(qta.icon("fa5s.window-close", color="#888"), "关闭文件框(保存)").triggered.connect(self._close_box)
menu.addAction(qta.icon("fa5s.trash", color="#e55"), "删除文件框(不可恢复)").triggered.connect(self._delete_box)
menu.exec(global_pos)
def _show_item_list_menu(self, idx: QModelIndex, global_pos):
"""拖入 / 分组模式:QListView 卡片级右键菜单。"""
path = idx.data(Qt.ItemDataRole.UserRole)
item_id = idx.data(Qt.ItemDataRole.UserRole + 1)
name = idx.data()
if not path: return
is_linked = self._is_group_linked
menu = QMenu(self); menu.setStyleSheet(_MENU_QSS)
def open_folder():
import ctypes
ctypes.windll.shell32.ShellExecuteW(None, "open", "explorer.exe", f'/select,"{path}"', None, 1)
def rename():
nonlocal idx, name
new_name, ok = dialog_style.get_text(self, "重命名", "新名称:", text=name)
if not ok or not new_name.strip(): return
new_name = new_name.strip()
if is_linked: database.update_item(item_id, name=new_name)
else: database.update_file_box_item(item_id, name=new_name)
self._item_model.itemFromIndex(idx).setText(new_name)
self._notify_panel_refresh()
def delete():
nonlocal idx
ret = dialog_style.question(self, "确认", f"删除「{name}」?", default_button=QMessageBox.StandardButton.No)
if ret != QMessageBox.StandardButton.Yes: return
if is_linked: database.delete_item(item_id)
else: database.delete_file_box_item(item_id)
self._item_model.removeRow(idx.row())
if self._item_model.rowCount() == 0:
self._empty_label.show(); self._item_list.hide()
self._notify_panel_refresh()
menu.addAction(qta.icon("fa5s.play", color="#4a9eff"), "打开").triggered.connect(lambda: self._open_item(idx))
menu.addAction(qta.icon("fa5s.folder-open", color="#aaa"), "打开文件夹位置").triggered.connect(open_folder)
menu.addAction(qta.icon("fa5s.pen", color="#aaa"), "重命名").triggered.connect(rename)
menu.addSeparator()
menu.addAction(qta.icon("fa5s.trash", color="#e55"), "删除").triggered.connect(delete)
menu.exec(global_pos)
def _show_folder_lv_item_menu(self, lv, idx: QModelIndex, global_pos):
"""文件夹浏览模式:QListView 条目级右键菜单。"""
path = lv.model().filePath(idx)
if not path or not os.path.exists(path): return
menu = QMenu(self); menu.setStyleSheet(_MENU_QSS)
menu.addAction(qta.icon("fa5s.play", color="#4a9eff"), "打开").triggered.connect(lambda: os.startfile(path))
def open_folder():
import ctypes
ctypes.windll.shell32.ShellExecuteW(None, "open", "explorer.exe", f'/select,"{path}"', None, 1)
menu.addAction(qta.icon("fa5s.folder-open", color="#aaa"), "打开文件夹位置").triggered.connect(open_folder)
menu.exec(global_pos)
def _pick_files(self):
files, _ = QFileDialog.getOpenFileNames(self, "选择文件")
if not files: return
added = 0
for p in files:
store = shortcut_target.path_for_storage(p)
name = shortcut_target.item_name_from_sources(p, store)
database.add_file_box_item(self._box_id, name, store); added += 1
if added: self.refresh_items()
def _pick_folders(self):
folder = QFileDialog.getExistingDirectory(self, "选择文件夹")
if not folder: return
store = shortcut_target.path_for_storage(folder)
name = os.path.basename(folder) or folder
database.add_file_box_item(self._box_id, name, store)
self.refresh_items()
def _create_new_folder_in_box(self):
"""在拖入模式下创建一个真实文件夹并添加到文件框。"""
name, ok = dialog_style.get_text(self, "新建文件夹", "文件夹名称:", text="新建文件夹")
if not ok or not name.strip(): return
# 先选父目录
parent = QFileDialog.getExistingDirectory(self, "选择创建位置")
if not parent: return
new_path = os.path.join(parent, name.strip())
try:
os.makedirs(new_path, exist_ok=False)
except Exception as e:
dialog_style.warning(self, "失败", str(e)); return
store = shortcut_target.path_for_storage(new_path)
database.add_file_box_item(self._box_id, name.strip(), store)
self.refresh_items()
def _do_resize(self, gp: QPoint):
if not self._resize_start_geo or not self._resize_start_global: return
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
min_w, min_h = self.MIN_W, 28 if self._collapsed else self.MIN_H
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 _do_snap(self):
t = self._SNAP_THRESHOLD; gap = self._BOX_GAP
sx, sy, sw, sh = self.x(), self.y(), self.width(), self.height(); changed = False
screen = QApplication.screenAt(self.geometry().center())
if not screen: screen = QApplication.primaryScreen()
geom = screen.availableGeometry()
candidates = []
if abs(sy - geom.top()) <= t: candidates.append(("top", geom.top()))
if abs((sy + sh) - geom.bottom()) <= t: candidates.append(("bottom", geom.bottom() - sh))
if abs(sx - geom.left()) <= t: candidates.append(("left", geom.left()))
if abs((sx + sw) - geom.right()) <= t: candidates.append(("right", geom.right() - sw))
if candidates:
edge, pos = min(candidates, key=lambda c: (abs(c[1] - sy) if c[0] in ("top", "bottom") else abs(c[1] - sx)))
if edge in ("top", "bottom"): sy = pos
else: sx = pos
changed = True
for other in FileBox._instances:
if other is self: continue
ox, oy, ow, oh = other.x(), other.y(), other.width(), other.height()
x_c = [("ll", abs(sx - ox), ox), ("lr", abs(sx - (ox + ow)), ox + ow + gap),
("rl", abs((sx + sw) - ox), ox - sw - gap), ("rr", abs((sx + sw) - (ox + ow)), ox + ow - sw),
("cx", abs((sx + sw/2) - (ox + ow/2)), ox + ow/2 - sw/2)]
y_c = [("tt", abs(sy - oy), oy), ("tb", abs(sy - (oy + oh)), oy + oh + gap),
("bt", abs((sy + sh) - oy), oy - sh - gap), ("bb", abs((sy + sh) - (oy + oh)), oy + oh - sh),
("cy", abs((sy + sh/2) - (oy + oh/2)), oy + oh/2 - sh/2)]
x_best = min(x_c, key=lambda c: c[1])
if x_best[1] <= t:
sx = int(x_best[2]); changed = True
if x_best[0] not in ("lr", "rl"): sw = max(sw, ow)
y_best = min(y_c, key=lambda c: c[1])
if y_best[1] <= t:
sy = int(y_best[2]); changed = True
if y_best[0] not in ("tb", "bt"): sh = max(sh, oh)
sx = max(geom.left(), min(sx, geom.right() - sw))
sy = max(geom.top(), min(sy, geom.bottom() - sh))
if changed or sx != self.x() or sy != self.y() or sw != self.width() or sh != self.height():
self._snap_anim = QPropertyAnimation(self, b"geometry")
self._snap_anim.setDuration(150); self._snap_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
self._snap_anim.setStartValue(self.geometry())
self._snap_anim.setEndValue(QRect(sx, sy, sw, sh))
self._snap_anim.start()
# ── 持久化 ────────────────────────────────────────────
def _save_all(self):
database.update_file_box(
self._box_id,
x=self.x(), y=self.y(), w=self.width(), h=self.height(),
collapsed=1 if self._collapsed else 0,
opacity=self._opacity,
folder_path=self._folder_path if self._is_folder_mode else "",
view_mode=self._view_mode if not self._is_folder_mode else "icon",
title=self._box_data.get("title", ""),
)
def closeEvent(self, event):
if self in FileBox._instances: FileBox._instances.remove(self)
self._save_all(); event.accept()
# ── 顶层窗口兜底拖放 ─────────────────────────────────
def dragEnterEvent(self, event):
if event.mimeData().hasUrls(): event.acceptProposedAction()
else: event.ignore()
def dragMoveEvent(self, event):
if event.mimeData().hasUrls(): event.acceptProposedAction()
def dropEvent(self, event):
self._handle_drop(event)