import os import sys import qtawesome as qta from PyQt6.QtWidgets import (QWidget, QLabel, QVBoxLayout, QSizePolicy, QMenu, QFileIconProvider, QMessageBox) from PyQt6.QtGui import ( QDrag, QPixmap, QPainter, QColor, QIcon, QPen, QKeySequence, QFont, QFontMetrics, QTextLayout, QTextOption, ) from PyQt6.QtCore import Qt, QMimeData, QByteArray, QSize, QPoint, QFileInfo, QPropertyAnimation, QEasingCurve from db import database import ui.theme as theme import ui.dialog_style as dialog_style import shortcut_target _icon_provider = QFileIconProvider() def _wrapped_line_count(text: str, font: QFont, width_px: int) -> int: if width_px < 4 or not text: return 0 if not text else 999 tl = QTextLayout(text, font) opt = QTextOption() opt.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere) tl.setTextOption(opt) tl.beginLayout() n = 0 while True: line = tl.createLine() if not line.isValid(): break line.setLineWidth(width_px) n += 1 tl.endLayout() return n def two_line_display_text(text: str, font: QFont, width_px: int) -> str: """最多按宽度折成 2 行;超出部分用省略号。""" if not text: return text if _wrapped_line_count(text, font, width_px) <= 2: return text elide = "…" lo, hi = 0, len(text) best = elide while lo <= hi: mid = (lo + hi) // 2 cand = text[:mid].rstrip() + elide if _wrapped_line_count(cand, font, width_px) <= 2: best = cand lo = mid + 1 else: hi = mid - 1 return best def extract_icon(path: str) -> QIcon: try: icon = _icon_provider.icon(QFileInfo(path)) if not icon.isNull(): px = icon.pixmap(QSize(36, 36)) if not px.isNull() and px.width() > 4: return icon except Exception: pass ext = os.path.splitext(path)[1].lower() color = "#4a9eff" if ext == ".exe": return qta.icon("fa5s.desktop", color=color) if ext == ".lnk": return qta.icon("fa5s.link", color=color) if ext in (".bat", ".cmd"): return qta.icon("fa5s.terminal", color=color) if ext == ".url": return qta.icon("fa5s.globe", color=color) return qta.icon("fa5s.file", color=color) class ItemWidget(QWidget): DRAG_THRESHOLD = 8 def __init__(self, item_data: dict, parent=None): super().__init__(parent) self.item_data = item_data self.setFixedSize(68, 76) self.setCursor(Qt.CursorShape.PointingHandCursor) self._drag_start_pos = QPoint() self._selected = False self._build_ui() def showEvent(self, event): super().showEvent(event) self._update_name_display() def resizeEvent(self, event): super().resizeEvent(event) self._update_name_display() def _build_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(4, 6, 4, 4) layout.setSpacing(3) icon = extract_icon(self.item_data["path"]) self.icon_label = QLabel() self.icon_label.setPixmap(icon.pixmap(QSize(36, 36))) self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.icon_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) self.name_label = QLabel() self.name_label.setAlignment(Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignTop) self.name_label.setWordWrap(True) self.name_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) self.name_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) self._apply_theme() layout.addWidget(self.icon_label) layout.addWidget(self.name_label) def _update_name_display(self): name = self.item_data.get("name") or "" w = max(8, self.width() - 8) font = self.name_label.font() disp = two_line_display_text(name, font, w) self.name_label.setText(disp) path = self.item_data.get("path") or "" self.setToolTip(f"{name}\n{path}" if path else name) def _apply_theme(self): t = theme.current() self.name_label.setStyleSheet(f"font-size:10px; color:{t['item_name_color']};") fm = QFontMetrics(self.name_label.font()) self.name_label.setMaximumHeight(int(fm.lineSpacing() * 2 + 4)) self._update_name_display() def matches(self, keyword: str) -> bool: return keyword.lower() in self.item_data["name"].lower() # ── 选中高亮 ───────────────────────────────────────── def _set_selected(self, val: bool): self._selected = val self.update() def paintEvent(self, event): if self._selected: p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) p.setBrush(QColor(74, 158, 255, 50)) p.setPen(QPen(QColor("#4a9eff"), 1.5)) p.drawRoundedRect(1, 1, self.width() - 2, self.height() - 2, 6, 6) p.end() super().paintEvent(event) # ── 鼠标事件 ───────────────────────────────────────── def mouseDoubleClickEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self._launch() def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self._drag_start_pos = event.pos() fc = self.parent() if fc and hasattr(fc, "_clear_selection_except"): fc._clear_selection_except(self) self._set_selected(True) super().mousePressEvent(event) def mouseReleaseEvent(self, event): super().mouseReleaseEvent(event) def mouseMoveEvent(self, event): if not (event.buttons() & Qt.MouseButton.LeftButton): return if (event.pos() - self._drag_start_pos).manhattanLength() < self.DRAG_THRESHOLD: return self._set_selected(False) drag = QDrag(self) mime = QMimeData() item_id = self.item_data.get("id") id_bytes = str(item_id).encode() if item_id is not None else b"none" mime.setData("application/x-item-id", QByteArray(id_bytes)) # 也携带文件路径,方便跨组拖拽文件夹分组的 item from PyQt6.QtCore import QUrl mime.setUrls([QUrl.fromLocalFile(self.item_data["path"])]) drag.setMimeData(mime) src = self.grab() scale = 1.4 pw, ph = int(src.width() * scale), int(src.height() * scale) preview = QPixmap(pw, ph) preview.fill(QColor(0, 0, 0, 0)) painter = QPainter(preview) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) painter.setBrush(QColor(60, 60, 60, 160)) painter.setPen(Qt.PenStyle.NoPen) painter.drawRoundedRect(0, 0, pw, ph, 8, 8) painter.setOpacity(0.92) painter.drawPixmap(0, 0, pw, ph, src) painter.end() drag.setPixmap(preview) drag.setHotSpot(QPoint(int(event.pos().x() * scale), int(event.pos().y() * scale))) drag.exec(Qt.DropAction.MoveAction) def contextMenuEvent(self, event): fc = self.parent() if fc and hasattr(fc, "_clear_selection_except"): if not self._selected: fc._clear_selection_except(self) self._set_selected(True) else: self._set_selected(True) t = theme.current() menu_style = f""" QMenu {{ background:{t['menu_bg']}; color:{t['menu_color']}; border:1px solid {t['menu_border']}; padding:4px; border-radius:6px; }} QMenu::item {{ padding:6px 18px; border-radius:3px; }} QMenu::item:selected {{ background:{t['menu_selected']}; }} """ menu = QMenu(self) menu.setStyleSheet(menu_style) open_act = menu.addAction(qta.icon("fa5s.play", color="#4a9eff"), "打开") menu.addSeparator() item_id = self.item_data.get("id") is_folder_item = item_id is None rename_act = menu.addAction(qta.icon("fa5s.pen", color="#aaa"), "重命名") if not is_folder_item: repath_act = menu.addAction(qta.icon("fa5s.folder-open", color="#aaa"), "更换路径") else: repath_act = None menu.addSeparator() del_act = menu.addAction(qta.icon("fa5s.trash", color="#e55"), "删除") # 菜单弹出时单键触发:O 打开 / M 重命名 / D 删除 _ctx = Qt.ShortcutContext.WidgetWithChildrenShortcut open_act.setShortcut(QKeySequence(Qt.Key.Key_O)) open_act.setShortcutContext(_ctx) rename_act.setShortcut(QKeySequence(Qt.Key.Key_M)) rename_act.setShortcutContext(_ctx) del_act.setShortcut(QKeySequence(Qt.Key.Key_D)) del_act.setShortcutContext(_ctx) action = menu.exec(event.globalPos()) if action == open_act: self._launch() elif action == rename_act: self._rename() elif repath_act and action == repath_act: self._change_path() elif action == del_act: self._delete_item() # ── 操作 ───────────────────────────────────────────── def _rename(self): is_folder_item = self.item_data.get("id") is None old_path = self.item_data["path"] ext = os.path.splitext(old_path)[1] # 文件夹分组显示带后缀的完整文件名,普通分组只显示名称 if is_folder_item: current_display = self.item_data["name"] + ext else: current_display = self.item_data["name"] name, ok = dialog_style.get_text(self, "重命名", "新名称:", text=current_display) if not ok or not name.strip() or name.strip() == current_display: return new_input = name.strip() if is_folder_item: # 用户输入的就是完整文件名(含后缀) new_filename = new_input new_name = os.path.splitext(new_filename)[0] or new_filename new_path = os.path.join(os.path.dirname(old_path), new_filename) try: os.rename(old_path, new_path) self.item_data["path"] = new_path self.item_data["name"] = new_name self._update_name_display() except Exception as e: dialog_style.warning(self, "重命名失败", str(e)) else: conn = database.get_conn() conn.execute("UPDATE items SET name=? WHERE id=?", (new_input, self.item_data["id"])) conn.commit() conn.close() self.item_data["name"] = new_input self._update_name_display() def _change_path(self): path, _ = dialog_style.get_open_file_name( self, "选择新路径", os.path.dirname(self.item_data["path"]), "程序/快捷方式 (*.exe *.lnk *.bat *.cmd *.url);;所有文件 (*)", ) if path: store = shortcut_target.path_for_storage(path) conn = database.get_conn() conn.execute("UPDATE items SET path=? WHERE id=?", (store, self.item_data["id"])) conn.commit() conn.close() self.item_data["path"] = store self.icon_label.setPixmap(extract_icon(store).pixmap(QSize(36, 36))) self._update_name_display() def _delete_item(self, skip_confirm: bool = False, skip_refresh: bool = False): item_path = self.item_data["path"] item_id = self.item_data.get("id") is_folder_item = item_id is None if is_folder_item and os.path.isfile(item_path): if not skip_confirm: ret = dialog_style.question( self, "删除确认", f"确认删除文件?\n\n{item_path}", buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, default_button=QMessageBox.StandardButton.Cancel, ) if ret != QMessageBox.StandardButton.Yes: return try: try: import send2trash send2trash.send2trash(item_path) except ImportError: os.remove(item_path) except Exception as e: dialog_style.warning(self, "删除失败", str(e)) return else: if not skip_confirm: ret = dialog_style.question( self, "确认", f"从分组中移除「{self.item_data['name']}」?", default_button=QMessageBox.StandardButton.No, ) if ret != QMessageBox.StandardButton.Yes: return if item_id is not None: database.delete_item(item_id) if skip_refresh: return p = self.parent() while p and not hasattr(p, "refresh"): p = p.parent() if p: p.refresh() def _launch(self): path = (self.item_data.get("path") or "").strip() if not path: return lp = path.lower() if lp.startswith(("http://", "https://")): import webbrowser webbrowser.open(path) return if sys.platform == "win32": path = os.path.normpath(path) try: if sys.platform == "win32": os.startfile(path) else: import subprocess subprocess.Popen(["xdg-open", path]) except OSError: try: import subprocess if sys.platform == "win32": # shell=True 无法直接“运行” .lnk,需交给 start 做关联打开 subprocess.Popen( ["cmd", "/c", "start", "", path], creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) else: subprocess.Popen(["xdg-open", path]) except Exception as e: print(f"启动失败: {path!r} -> {e}")