# -*- 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)