955 lines
47 KiB
Python
955 lines
47 KiB
Python
# -*- 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)
|