Files
niumasoftware/ui/item.py
T
2026-09-23 16:51:40 +08:00

436 lines
16 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
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"), "打开")
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(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
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}")