Files
niumasoftware/ui/item.py
T
2026-10-12 14:57:46 +08:00

466 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
from db import database
from ui.flow_layout import ITEM_W
import ui.theme as theme
import ui.dialog_style as dialog_style
import shortcut_target
_icon_provider = QFileIconProvider()
# 图标缓存:同一文件路径在刷新/重建条目时避免重复读磁盘提取图标
_icon_cache: dict[str, QIcon] = {}
_ICON_CACHE_MAX = 512
def _cached_icon(key: str, loader) -> QIcon:
icon = _icon_cache.get(key)
if icon is None:
icon = loader()
if len(_icon_cache) >= _ICON_CACHE_MAX:
_icon_cache.clear()
_icon_cache[key] = icon
return icon
def get_icon_width() -> int:
"""设置中的图标宽度(限制在 48~120),ItemWidget 尺寸以此为准。"""
try:
w = int(database.get_setting("icon_width", str(ITEM_W)))
except Exception:
w = ITEM_W
return max(48, min(120, w))
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:
key = os.path.normcase(path)
def _load() -> QIcon:
try:
icon = _icon_provider.icon(QFileInfo(path))
if not icon.isNull():
px = icon.pixmap(QSize(64, 64))
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)
return _cached_icon(key, _load)
class ItemWidget(QWidget):
DRAG_THRESHOLD = 8
def __init__(self, item_data: dict, parent=None):
super().__init__(parent)
self.item_data = item_data
# 尺寸跟随设置中的「图标宽度」,高度 = 宽度 + 文本区
w = get_icon_width()
self._icon_px = max(24, int(w * 0.53))
self.setFixedSize(w, w + 8)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self._drag_start_pos = QPoint()
self._selected = False
self._hovered = 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, 2)
layout.setSpacing(3)
icon = extract_icon(self.item_data["path"])
self.icon_label = QLabel()
self.icon_label.setPixmap(icon.pixmap(QSize(self._icon_px, self._icon_px)))
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 enterEvent(self, event):
self._hovered = True
self.update()
super().enterEvent(event)
def leaveEvent(self, event):
self._hovered = False
self.update()
super().leaveEvent(event)
def paintEvent(self, event):
if self._selected or self._hovered:
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
if self._selected:
p.setBrush(QColor(74, 158, 255, 50))
p.setPen(QPen(QColor("#4a9eff"), 1.5))
else:
p.setBrush(QColor(128, 128, 128, 36))
p.setPen(Qt.PenStyle.NoPen)
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"), "打开")
open_folder_act = menu.addAction(qta.icon("fa5s.folder-open", color="#aaa"), "打开文件夹位置")
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 == open_folder_act:
self._open_folder()
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(self._icon_px, self._icon_px)))
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
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
if sys.platform == "win32":
# 尝试直接启动(适用于 .exe / .bat / .cmd)
ext = os.path.splitext(path)[1].lower()
if ext in (".exe", ".bat", ".cmd"):
subprocess.Popen([path], creationflags=creationflags)
else:
# .lnk / .url 等走 start 关联打开
subprocess.Popen(
["cmd", "/c", "start", "", path],
creationflags=creationflags,
)
else:
subprocess.Popen(["xdg-open", path])
except Exception as e:
print(f"启动失败: {path!r} -> {e}")
def _open_folder(self):
"""在系统资源管理器中打开文件所在文件夹并选中该文件。"""
path = (self.item_data.get("path") or "").strip()
if not path:
return
path = os.path.normpath(path)
try:
if sys.platform == "win32":
# explorer /select,"路径" —— /select,"..." 必须作为一个整体参数
import ctypes
ctypes.windll.shell32.ShellExecuteW(
None, "open", "explorer.exe",
'/select,"{}"'.format(path),
None, 1, # SW_SHOWNORMAL
)
else:
folder = os.path.dirname(path)
subprocess.Popen(["xdg-open", folder])
except Exception as e:
print(f"打开文件夹失败: {path!r} -> {e}")