import os from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QMessageBox, QMenu, QSizePolicy ) from PyQt6.QtCore import Qt, pyqtSignal, QSize, QPoint, QMimeData, QByteArray from PyQt6.QtGui import ( QDragEnterEvent, QDragMoveEvent, QDropEvent, QCursor, QDrag, QPainter, QColor, QPen, QShortcut, QKeySequence, ) from db import database from ui.item import ItemWidget from ui.flow_layout import FlowContainer import ui.theme as theme import qtawesome as qta import shortcut_target import ui.dialog_style as dialog_style def item_count_for_group(group_id: int) -> int: """分组内程序/文件总数(文件夹分组数磁盘文件,普通分组数数据库条目)。""" groups = database.get_groups() group_info = next((g for g in groups if g["id"] == group_id), {}) folder_path = (group_info.get("folder_path", "") or "").strip() if folder_path and os.path.isdir(folder_path): n = 0 try: import stat as _stat for fname in os.listdir(folder_path): fpath = os.path.join(folder_path, fname) try: if _stat.S_ISREG(os.stat(fpath).st_mode): n += 1 except OSError: continue except OSError: pass return n return len(database.get_items(group_id)) class DragHandle(QLabel): """分组拖拽排序手柄""" def __init__(self, group_widget, parent=None): super().__init__(parent) self.group_widget = group_widget self.setFixedSize(18, 18) self.setCursor(Qt.CursorShape.SizeVerCursor) self.setToolTip("拖拽排序") self.setPixmap(qta.icon("fa5s.grip-vertical", color="#888").pixmap(14, 14)) def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self._drag_start = event.pos() def mouseMoveEvent(self, event): if not (event.buttons() & Qt.MouseButton.LeftButton): return if (event.pos() - self._drag_start).manhattanLength() < 6: return drag = QDrag(self) mime = QMimeData() gid = self.group_widget.group_data["id"] mime.setData("application/x-group-id", QByteArray(str(gid).encode())) drag.setMimeData(mime) px = self.group_widget.header.grab() drag.setPixmap(px) drag.setHotSpot(QPoint(px.width() // 2, px.height() // 2)) drag.exec(Qt.DropAction.MoveAction) class FlowWidget(QWidget): item_dropped = pyqtSignal(int) refreshed = pyqtSignal() def __init__(self, group_id, parent=None): super().__init__(parent) self.group_id = group_id self.setAcceptDrops(True) self._drop_indicator = -1 layout = QVBoxLayout(self) layout.setContentsMargins(2, 2, 2, 2) layout.setSpacing(0) self._container = FlowContainer(self) layout.addWidget(self._container) layout.addStretch() self.setFocusPolicy(Qt.FocusPolicy.ClickFocus) _del_sc = QShortcut(QKeySequence.StandardKey.Delete, self) _del_sc.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) _del_sc.activated.connect(self._delete_selected_items) _esc_sc = QShortcut(QKeySequence(Qt.Key.Key_Escape), self) _esc_sc.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) _esc_sc.activated.connect(self._container.clear_selection) self.refresh() def refresh(self, keyword=""): self._container.clear_widgets() # 判断是否是文件夹分组 groups = database.get_groups() group_info = next((g for g in groups if g["id"] == self.group_id), {}) folder_path = (group_info.get("folder_path", "") or "").strip() if folder_path and os.path.isdir(folder_path): # 文件夹分组:直接读目录,不走数据库 items = [] try: for fname in sorted(os.listdir(folder_path)): fpath = os.path.join(folder_path, fname) try: import stat as _stat if _stat.S_ISREG(os.stat(fpath).st_mode): name = os.path.splitext(fname)[0] or fname items.append({"id": None, "name": name, "path": fpath, "group_id": self.group_id}) except Exception: continue except Exception: pass else: # 普通分组:走数据库 items = database.get_items(self.group_id) for data in items: iw = ItemWidget(data, self._container) iw.show() if keyword and not iw.matches(keyword): iw.hide() self._container.add_widget(iw) self._container._schedule_relayout() self.updateGeometry() self.refreshed.emit() def apply_theme(self): for w in self._container.get_widgets(): w._apply_theme() def filter(self, keyword: str): # 不重建,只切换可见性,然后重新布局 for w in self._container.get_widgets(): if keyword: w.setVisible(w.matches(keyword)) else: w.show() self._container._relayout() self.updateGeometry() def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: cg = self._container.geometry() if not cg.contains(event.pos()): lp = self._container.mapFrom(self, event.pos()) w, h = max(1, self._container.width()), max(1, self._container.height()) lp.setX(max(0, min(lp.x(), w - 1))) lp.setY(max(0, min(lp.y(), h - 1))) self._container._begin_rubber(lp) return super().mousePressEvent(event) def _delete_selected_items(self): widgets = [w for w in self._container.get_widgets() if getattr(w, "_selected", False)] if not widgets: return ret = dialog_style.question( self, "批量删除", f"确定删除选中的 {len(widgets)} 项?", default_button=QMessageBox.StandardButton.No, ) if ret != QMessageBox.StandardButton.Yes: return for w in widgets: w._delete_item(skip_confirm=True, skip_refresh=True) self.refresh() def dragEnterEvent(self, event: QDragEnterEvent): if event.mimeData().hasFormat("application/x-item-id") or \ event.mimeData().hasUrls(): self._container.set_drag_highlight(True) event.acceptProposedAction() else: event.ignore() def dragMoveEvent(self, event: QDragMoveEvent): if event.mimeData().hasFormat("application/x-item-id"): pos = self._container.mapFrom(self, event.position().toPoint()) self._drop_indicator = self._container.index_at(pos) self.update() event.acceptProposedAction() def dragLeaveEvent(self, event): self._container.set_drag_highlight(False) self._drop_indicator = -1 self.update() def paintEvent(self, event): super().paintEvent(event) if self._drop_indicator < 0: return from PyQt6.QtGui import QPainter, QColor, QPen all_w = self._container.get_widgets() if not all_w: return pos = self._drop_indicator p = QPainter(self) p.setPen(QPen(QColor("#4a9eff"), 2)) offset = self._container.pos() def _draw_vline(x_local: int, g): x = offset.x() + x_local y1 = offset.y() + g.top() y2 = offset.y() + g.bottom() p.drawLine(x, y1, x, y2) if pos >= len(all_w): for j in range(len(all_w) - 1, -1, -1): if all_w[j].isVisible(): g = all_w[j].geometry() _draw_vline(g.right() + 2, g) break p.end() return w = all_w[pos] if w.isVisible(): g = w.geometry() _draw_vline(g.left() - 2, g) else: placed = False for j in range(pos, len(all_w)): if all_w[j].isVisible(): g = all_w[j].geometry() _draw_vline(g.left() - 2, g) placed = True break if not placed: for j in range(pos - 1, -1, -1): if all_w[j].isVisible(): g = all_w[j].geometry() _draw_vline(g.right() + 2, g) break p.end() def dropEvent(self, event: QDropEvent): self._container.set_drag_highlight(False) self._drop_indicator = -1 self.update() mime = event.mimeData() if mime.hasFormat("application/x-item-id"): item_id = int(mime.data("application/x-item-id").data().decode()) pos = self._container.mapFrom(self, event.position().toPoint()) insert_pos = self._container.index_at(pos) database.move_item(item_id, self.group_id, 999) items = database.get_items(self.group_id) ids = [it["id"] for it in items if it["id"] != item_id] ids.insert(min(insert_pos, len(ids)), item_id) database.reorder_items(self.group_id, ids) self.refresh() self.item_dropped.emit(item_id) event.acceptProposedAction() elif mime.hasUrls(): for url in mime.urls(): path = url.toLocalFile() if path: store = shortcut_target.path_for_storage(path) name = shortcut_target.item_name_from_sources(path, store) database.add_item(self.group_id, name, store) self.refresh() event.acceptProposedAction() else: event.ignore() def contextMenuEvent(self, event): from PyQt6.QtWidgets import QApplication t = theme.current() menu_style = f""" QMenu {{ background:{t['menu_bg']}; color:{t['menu_color']}; border:1px solid {t['menu_border']}; padding:4px; border-radius:6px; }} QMenu::item {{ padding:5px 16px; border-radius:3px; }} QMenu::item:selected {{ background:{t['menu_selected']}; }} QMenu::item:disabled {{ color:#666; }} """ menu = QMenu(self) menu.setStyleSheet(menu_style) refresh_act = menu.addAction("🔄 刷新分组") menu.addSeparator() # 检测剪贴板 clipboard = QApplication.clipboard() mime = clipboard.mimeData() paste_act = None clipboard_paths = [] if mime and mime.hasUrls(): # 获取本分组信息 groups = database.get_groups() group_info = next((g for g in groups if g["id"] == self.group_id), {}) is_folder_group = bool(group_info.get("folder_path", "")) # 快捷方式扩展名 shortcut_exts = {".exe", ".lnk", ".bat", ".cmd", ".url"} for url in mime.urls(): path = url.toLocalFile() if not path: continue ext = os.path.splitext(path)[1].lower() if is_folder_group: # 文件夹分组:接受所有文件 if os.path.isfile(path): clipboard_paths.append(path) else: # 普通分组:只接受快捷方式/程序 if ext in shortcut_exts: clipboard_paths.append(path) if clipboard_paths: label = f"📋 粘贴 {len(clipboard_paths)} 个文件" if len(clipboard_paths) > 1 \ else f"📋 粘贴「{os.path.basename(clipboard_paths[0])}」" paste_act = menu.addAction(label) else: act = menu.addAction("📋 粘贴(无可用文件)") act.setEnabled(False) else: act = menu.addAction("📋 粘贴(剪贴板为空)") act.setEnabled(False) action = menu.exec(event.globalPos()) if action == refresh_act: self.refresh() elif paste_act and action == paste_act: groups = database.get_groups() group_info = next((g for g in groups if g["id"] == self.group_id), {}) folder_path = group_info.get("folder_path", "") is_folder_group = bool(folder_path) for path in clipboard_paths: if is_folder_group: # 真正复制文件到文件夹目录 import shutil dest_path = os.path.join(folder_path, os.path.basename(path)) # 避免覆盖同名文件,自动重命名 if os.path.exists(dest_path) and dest_path != path: base, ext = os.path.splitext(os.path.basename(path)) i = 1 while os.path.exists(dest_path): dest_path = os.path.join(folder_path, f"{base} ({i}){ext}") i += 1 try: if path != dest_path: shutil.copy2(path, dest_path) final_path = dest_path except Exception as e: print(f"复制失败: {path} -> {e}") final_path = path # 复制失败就用原路径 name = os.path.splitext(os.path.basename(final_path))[0] or os.path.basename(final_path) else: final_path = shortcut_target.path_for_storage(path) name = shortcut_target.item_name_from_sources(path, final_path) database.add_item(self.group_id, name, final_path) self.refresh() class GroupWidget(QWidget): request_refresh_all = pyqtSignal() def __init__(self, group_data: dict, parent=None): super().__init__(parent) self.group_data = group_data self.collapsed = False self._build_ui() self._apply_theme() def _build_ui(self): self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(0, 0, 0, 4) self.main_layout.setSpacing(0) self.header = QWidget() self.header.setFixedHeight(32) self.header.setCursor(Qt.CursorShape.PointingHandCursor) self.header.mousePressEvent = lambda e: self._toggle() \ if e.button() == Qt.MouseButton.LeftButton else None h_layout = QHBoxLayout(self.header) h_layout.setContentsMargins(4, 0, 6, 0) h_layout.setSpacing(4) # 拖拽排序手柄 self._drag_handle = DragHandle(self) self.toggle_icon = QLabel() self.toggle_icon.setFixedSize(14, 14) self.toggle_icon.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) self.title_label = QLabel() self.title_label.setStyleSheet("font-size:12px; font-weight:bold; background:transparent;") self.title_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) folder_path = self.group_data.get("folder_path", "") or "" # 只显示最后两级路径,避免太长 if folder_path: parts = folder_path.replace("\\", "/").rstrip("/").split("/") display_path = "/".join(parts[-2:]) if len(parts) >= 2 else folder_path else: display_path = "" self.path_label = QLabel(display_path) self.path_label.setStyleSheet("font-size:9px; color:#888; background:transparent;") self.path_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) self.path_label.setVisible(bool(folder_path)) self.path_label.setMaximumWidth(110) self.path_label.setToolTip(folder_path) self.path_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) self._add_btn = QPushButton() self._add_btn.setFixedSize(22, 22) self._add_btn.setToolTip("添加程序") self._add_btn.setStyleSheet("border:none; background:transparent;") self._add_btn.clicked.connect(self._add_item_dialog) self._add_btn.mousePressEvent = lambda e: ( self._add_btn.click() if e.button() == Qt.MouseButton.LeftButton else None ) self._more_btn = QPushButton() self._more_btn.setFixedSize(22, 22) self._more_btn.setStyleSheet("border:none; background:transparent;") self._more_btn.clicked.connect(self._group_menu) h_layout.addWidget(self._drag_handle) h_layout.addWidget(self.toggle_icon) h_layout.addWidget(self.title_label) h_layout.addStretch() h_layout.addWidget(self.path_label) h_layout.addWidget(self._add_btn) h_layout.addWidget(self._more_btn) self.flow = FlowWidget(self.group_data["id"]) self.flow.item_dropped.connect(lambda _: self.request_refresh_all.emit()) self.flow.refreshed.connect(self._update_title_count) self._update_title_count() self.main_layout.addWidget(self.header) self.main_layout.addWidget(self.flow) def _update_title_count(self): n = item_count_for_group(self.group_data["id"]) self.title_label.setText(f"{self.group_data['name']} ({n})") def _apply_theme(self): t = theme.current() ic = "#cccccc" if theme.name() == "dark" else "#555555" self.header.setStyleSheet(f""" QWidget {{ background:{t['header_bg']}; border-radius:5px; }} QWidget:hover {{ background:{t['header_hover']}; }} """) self.title_label.setStyleSheet( f"color:{t['item_name_color']}; font-size:12px; font-weight:bold; background:transparent;" ) # 展开/收缩图标 arrow = "fa5s.chevron-down" if not self.collapsed else "fa5s.chevron-right" self.toggle_icon.setPixmap(qta.icon(arrow, color=ic).pixmap(14, 14)) self._add_btn.setIcon(qta.icon("fa5s.plus", color=ic)) self._add_btn.setIconSize(QSize(11, 11)) self._more_btn.setIcon(qta.icon("fa5s.ellipsis-h", color=ic)) self._more_btn.setIconSize(QSize(11, 11)) self._drag_handle.setPixmap(qta.icon("fa5s.grip-vertical", color=ic).pixmap(14, 14)) path_color = "#777" if theme.name() == "dark" else "#999" self.path_label.setStyleSheet( f"font-size:9px; color:{path_color}; background:transparent;" ) self.flow.apply_theme() def _toggle(self): self.collapsed = not self.collapsed self.flow.setVisible(not self.collapsed) if not self.collapsed: self.flow.updateGeometry() self.updateGeometry() # 更新箭头图标 t = theme.current() ic = "#cccccc" if theme.name() == "dark" else "#555555" arrow = "fa5s.chevron-down" if not self.collapsed else "fa5s.chevron-right" self.toggle_icon.setPixmap(qta.icon(arrow, color=ic).pixmap(14, 14)) def _add_item_dialog(self): path, _ = dialog_style.get_open_file_name( self, "选择程序或快捷方式", "", "程序/快捷方式 (*.exe *.lnk *.bat *.cmd *.url);;所有文件 (*)", ) if path: store = shortcut_target.path_for_storage(path) name = shortcut_target.item_name_from_sources(path, store) database.add_item(self.group_data["id"], name, store) self.flow.refresh() def _group_menu(self): t = theme.current() menu = QMenu(self) menu.setStyleSheet(f""" QMenu {{ background:{t['menu_bg']}; color:{t['menu_color']}; border:1px solid {t['menu_border']}; padding:4px; border-radius:6px; }} QMenu::item {{ padding:5px 16px; border-radius:3px; }} QMenu::item:selected {{ background:{t['menu_selected']}; }} """) rename_act = menu.addAction("重命名") del_act = menu.addAction("删除分组") action = menu.exec(QCursor.pos()) if action == rename_act: name, ok = dialog_style.get_text( self, "重命名", "新名称:", text=self.group_data["name"] ) if ok and name.strip(): database.rename_group(self.group_data["id"], name.strip()) self.group_data["name"] = name.strip() self._update_title_count() elif action == del_act: ret = dialog_style.question( self, "确认", f"删除分组「{self.group_data['name']}」及其所有程序?", default_button=QMessageBox.StandardButton.No, ) if ret == QMessageBox.StandardButton.Yes: database.delete_group(self.group_data["id"]) self.request_refresh_all.emit() def refresh(self, keyword=""): self.flow.refresh(keyword) def filter(self, keyword: str): self.flow.filter(keyword) if keyword and self.collapsed: self._toggle()