390 lines
14 KiB
Python
390 lines
14 KiB
Python
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}")
|