niumasoftware/ui/item.py
2026-04-03 21:50:36 +08:00

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