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

547 lines
21 KiB
Python

import os
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QMessageBox, QMenu, QSizePolicy
)
from PyQt6.QtCore import Qt, pyqtSignal, QSize, QPoint, QMimeData, QByteArray
from PyQt6.QtGui import (
QDragEnterEvent, QDragMoveEvent, QDropEvent, QCursor, QDrag, QPainter, QColor, QPen,
QShortcut, QKeySequence,
)
from db import database
from ui.item import ItemWidget
from ui.flow_layout import FlowContainer
import ui.theme as theme
import qtawesome as qta
import shortcut_target
import ui.dialog_style as dialog_style
def item_count_for_group(group_id: int) -> int:
"""分组内程序/文件总数(文件夹分组数磁盘文件,普通分组数数据库条目)。"""
groups = database.get_groups()
group_info = next((g for g in groups if g["id"] == group_id), {})
folder_path = (group_info.get("folder_path", "") or "").strip()
if folder_path and os.path.isdir(folder_path):
n = 0
try:
import stat as _stat
for fname in os.listdir(folder_path):
fpath = os.path.join(folder_path, fname)
try:
if _stat.S_ISREG(os.stat(fpath).st_mode):
n += 1
except OSError:
continue
except OSError:
pass
return n
return len(database.get_items(group_id))
class DragHandle(QLabel):
"""分组拖拽排序手柄"""
def __init__(self, group_widget, parent=None):
super().__init__(parent)
self.group_widget = group_widget
self.setFixedSize(18, 18)
self.setCursor(Qt.CursorShape.SizeVerCursor)
self.setToolTip("拖拽排序")
self.setPixmap(qta.icon("fa5s.grip-vertical", color="#888").pixmap(14, 14))
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._drag_start = event.pos()
def mouseMoveEvent(self, event):
if not (event.buttons() & Qt.MouseButton.LeftButton):
return
if (event.pos() - self._drag_start).manhattanLength() < 6:
return
drag = QDrag(self)
mime = QMimeData()
gid = self.group_widget.group_data["id"]
mime.setData("application/x-group-id", QByteArray(str(gid).encode()))
drag.setMimeData(mime)
px = self.group_widget.header.grab()
drag.setPixmap(px)
drag.setHotSpot(QPoint(px.width() // 2, px.height() // 2))
drag.exec(Qt.DropAction.MoveAction)
class FlowWidget(QWidget):
item_dropped = pyqtSignal(int)
refreshed = pyqtSignal()
def __init__(self, group_id, parent=None):
super().__init__(parent)
self.group_id = group_id
self.setAcceptDrops(True)
self._drop_indicator = -1
layout = QVBoxLayout(self)
layout.setContentsMargins(2, 2, 2, 2)
layout.setSpacing(0)
self._container = FlowContainer(self)
layout.addWidget(self._container)
layout.addStretch()
self.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
_del_sc = QShortcut(QKeySequence.StandardKey.Delete, self)
_del_sc.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
_del_sc.activated.connect(self._delete_selected_items)
_esc_sc = QShortcut(QKeySequence(Qt.Key.Key_Escape), self)
_esc_sc.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
_esc_sc.activated.connect(self._container.clear_selection)
self.refresh()
def refresh(self, keyword=""):
self._container.clear_widgets()
# 判断是否是文件夹分组
groups = database.get_groups()
group_info = next((g for g in groups if g["id"] == self.group_id), {})
folder_path = (group_info.get("folder_path", "") or "").strip()
if folder_path and os.path.isdir(folder_path):
# 文件夹分组:直接读目录,不走数据库
items = []
try:
for fname in sorted(os.listdir(folder_path)):
fpath = os.path.join(folder_path, fname)
try:
import stat as _stat
if _stat.S_ISREG(os.stat(fpath).st_mode):
name = os.path.splitext(fname)[0] or fname
items.append({"id": None, "name": name,
"path": fpath, "group_id": self.group_id})
except Exception:
continue
except Exception:
pass
else:
# 普通分组:走数据库
items = database.get_items(self.group_id)
for data in items:
iw = ItemWidget(data, self._container)
iw.show()
if keyword and not iw.matches(keyword):
iw.hide()
self._container.add_widget(iw)
self._container._schedule_relayout()
self.updateGeometry()
self.refreshed.emit()
def apply_theme(self):
for w in self._container.get_widgets():
w._apply_theme()
def filter(self, keyword: str):
# 不重建,只切换可见性,然后重新布局
for w in self._container.get_widgets():
if keyword:
w.setVisible(w.matches(keyword))
else:
w.show()
self._container._relayout()
self.updateGeometry()
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
cg = self._container.geometry()
if not cg.contains(event.pos()):
lp = self._container.mapFrom(self, event.pos())
w, h = max(1, self._container.width()), max(1, self._container.height())
lp.setX(max(0, min(lp.x(), w - 1)))
lp.setY(max(0, min(lp.y(), h - 1)))
self._container._begin_rubber(lp)
return
super().mousePressEvent(event)
def _delete_selected_items(self):
widgets = [w for w in self._container.get_widgets() if getattr(w, "_selected", False)]
if not widgets:
return
ret = dialog_style.question(
self,
"批量删除",
f"确定删除选中的 {len(widgets)} 项?",
default_button=QMessageBox.StandardButton.No,
)
if ret != QMessageBox.StandardButton.Yes:
return
for w in widgets:
w._delete_item(skip_confirm=True, skip_refresh=True)
self.refresh()
def dragEnterEvent(self, event: QDragEnterEvent):
if event.mimeData().hasFormat("application/x-item-id") or \
event.mimeData().hasUrls():
self._container.set_drag_highlight(True)
event.acceptProposedAction()
else:
event.ignore()
def dragMoveEvent(self, event: QDragMoveEvent):
if event.mimeData().hasFormat("application/x-item-id"):
pos = self._container.mapFrom(self, event.position().toPoint())
self._drop_indicator = self._container.index_at(pos)
self.update()
event.acceptProposedAction()
def dragLeaveEvent(self, event):
self._container.set_drag_highlight(False)
self._drop_indicator = -1
self.update()
def paintEvent(self, event):
super().paintEvent(event)
if self._drop_indicator < 0:
return
from PyQt6.QtGui import QPainter, QColor, QPen
all_w = self._container.get_widgets()
if not all_w:
return
pos = self._drop_indicator
p = QPainter(self)
p.setPen(QPen(QColor("#4a9eff"), 2))
offset = self._container.pos()
def _draw_vline(x_local: int, g):
x = offset.x() + x_local
y1 = offset.y() + g.top()
y2 = offset.y() + g.bottom()
p.drawLine(x, y1, x, y2)
if pos >= len(all_w):
for j in range(len(all_w) - 1, -1, -1):
if all_w[j].isVisible():
g = all_w[j].geometry()
_draw_vline(g.right() + 2, g)
break
p.end()
return
w = all_w[pos]
if w.isVisible():
g = w.geometry()
_draw_vline(g.left() - 2, g)
else:
placed = False
for j in range(pos, len(all_w)):
if all_w[j].isVisible():
g = all_w[j].geometry()
_draw_vline(g.left() - 2, g)
placed = True
break
if not placed:
for j in range(pos - 1, -1, -1):
if all_w[j].isVisible():
g = all_w[j].geometry()
_draw_vline(g.right() + 2, g)
break
p.end()
def dropEvent(self, event: QDropEvent):
self._container.set_drag_highlight(False)
self._drop_indicator = -1
self.update()
mime = event.mimeData()
if mime.hasFormat("application/x-item-id"):
item_id = int(mime.data("application/x-item-id").data().decode())
pos = self._container.mapFrom(self, event.position().toPoint())
insert_pos = self._container.index_at(pos)
database.move_item(item_id, self.group_id, 999)
items = database.get_items(self.group_id)
ids = [it["id"] for it in items if it["id"] != item_id]
ids.insert(min(insert_pos, len(ids)), item_id)
database.reorder_items(self.group_id, ids)
self.refresh()
self.item_dropped.emit(item_id)
event.acceptProposedAction()
elif mime.hasUrls():
for url in mime.urls():
path = url.toLocalFile()
if path:
store = shortcut_target.path_for_storage(path)
name = shortcut_target.item_name_from_sources(path, store)
database.add_item(self.group_id, name, store)
self.refresh()
event.acceptProposedAction()
else:
event.ignore()
def contextMenuEvent(self, event):
from PyQt6.QtWidgets import QApplication
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:5px 16px; border-radius:3px; }}
QMenu::item:selected {{ background:{t['menu_selected']}; }}
QMenu::item:disabled {{ color:#666; }}
"""
menu = QMenu(self)
menu.setStyleSheet(menu_style)
refresh_act = menu.addAction("🔄 刷新分组")
menu.addSeparator()
# 检测剪贴板
clipboard = QApplication.clipboard()
mime = clipboard.mimeData()
paste_act = None
clipboard_paths = []
if mime and mime.hasUrls():
# 获取本分组信息
groups = database.get_groups()
group_info = next((g for g in groups if g["id"] == self.group_id), {})
is_folder_group = bool(group_info.get("folder_path", ""))
# 快捷方式扩展名
shortcut_exts = {".exe", ".lnk", ".bat", ".cmd", ".url"}
for url in mime.urls():
path = url.toLocalFile()
if not path:
continue
ext = os.path.splitext(path)[1].lower()
if is_folder_group:
# 文件夹分组:接受所有文件
if os.path.isfile(path):
clipboard_paths.append(path)
else:
# 普通分组:只接受快捷方式/程序
if ext in shortcut_exts:
clipboard_paths.append(path)
if clipboard_paths:
label = f"📋 粘贴 {len(clipboard_paths)} 个文件" if len(clipboard_paths) > 1 \
else f"📋 粘贴「{os.path.basename(clipboard_paths[0])}"
paste_act = menu.addAction(label)
else:
act = menu.addAction("📋 粘贴(无可用文件)")
act.setEnabled(False)
else:
act = menu.addAction("📋 粘贴(剪贴板为空)")
act.setEnabled(False)
action = menu.exec(event.globalPos())
if action == refresh_act:
self.refresh()
elif paste_act and action == paste_act:
groups = database.get_groups()
group_info = next((g for g in groups if g["id"] == self.group_id), {})
folder_path = group_info.get("folder_path", "")
is_folder_group = bool(folder_path)
for path in clipboard_paths:
if is_folder_group:
# 真正复制文件到文件夹目录
import shutil
dest_path = os.path.join(folder_path, os.path.basename(path))
# 避免覆盖同名文件,自动重命名
if os.path.exists(dest_path) and dest_path != path:
base, ext = os.path.splitext(os.path.basename(path))
i = 1
while os.path.exists(dest_path):
dest_path = os.path.join(folder_path, f"{base} ({i}){ext}")
i += 1
try:
if path != dest_path:
shutil.copy2(path, dest_path)
final_path = dest_path
except Exception as e:
print(f"复制失败: {path} -> {e}")
final_path = path # 复制失败就用原路径
name = os.path.splitext(os.path.basename(final_path))[0] or os.path.basename(final_path)
else:
final_path = shortcut_target.path_for_storage(path)
name = shortcut_target.item_name_from_sources(path, final_path)
database.add_item(self.group_id, name, final_path)
self.refresh()
class GroupWidget(QWidget):
request_refresh_all = pyqtSignal()
def __init__(self, group_data: dict, parent=None):
super().__init__(parent)
self.group_data = group_data
self.collapsed = False
self._build_ui()
self._apply_theme()
def _build_ui(self):
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 4)
self.main_layout.setSpacing(0)
self.header = QWidget()
self.header.setFixedHeight(32)
self.header.setCursor(Qt.CursorShape.PointingHandCursor)
self.header.mousePressEvent = lambda e: self._toggle() \
if e.button() == Qt.MouseButton.LeftButton else None
h_layout = QHBoxLayout(self.header)
h_layout.setContentsMargins(4, 0, 6, 0)
h_layout.setSpacing(4)
# 拖拽排序手柄
self._drag_handle = DragHandle(self)
self.toggle_icon = QLabel()
self.toggle_icon.setFixedSize(14, 14)
self.toggle_icon.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
self.title_label = QLabel()
self.title_label.setStyleSheet("font-size:12px; font-weight:bold; background:transparent;")
self.title_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
folder_path = self.group_data.get("folder_path", "") or ""
# 只显示最后两级路径,避免太长
if folder_path:
parts = folder_path.replace("\\", "/").rstrip("/").split("/")
display_path = "/".join(parts[-2:]) if len(parts) >= 2 else folder_path
else:
display_path = ""
self.path_label = QLabel(display_path)
self.path_label.setStyleSheet("font-size:9px; color:#888; background:transparent;")
self.path_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
self.path_label.setVisible(bool(folder_path))
self.path_label.setMaximumWidth(110)
self.path_label.setToolTip(folder_path)
self.path_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._add_btn = QPushButton()
self._add_btn.setFixedSize(22, 22)
self._add_btn.setToolTip("添加程序")
self._add_btn.setStyleSheet("border:none; background:transparent;")
self._add_btn.clicked.connect(self._add_item_dialog)
self._add_btn.mousePressEvent = lambda e: (
self._add_btn.click() if e.button() == Qt.MouseButton.LeftButton else None
)
self._more_btn = QPushButton()
self._more_btn.setFixedSize(22, 22)
self._more_btn.setStyleSheet("border:none; background:transparent;")
self._more_btn.clicked.connect(self._group_menu)
h_layout.addWidget(self._drag_handle)
h_layout.addWidget(self.toggle_icon)
h_layout.addWidget(self.title_label)
h_layout.addStretch()
h_layout.addWidget(self.path_label)
h_layout.addWidget(self._add_btn)
h_layout.addWidget(self._more_btn)
self.flow = FlowWidget(self.group_data["id"])
self.flow.item_dropped.connect(lambda _: self.request_refresh_all.emit())
self.flow.refreshed.connect(self._update_title_count)
self._update_title_count()
self.main_layout.addWidget(self.header)
self.main_layout.addWidget(self.flow)
def _update_title_count(self):
n = item_count_for_group(self.group_data["id"])
self.title_label.setText(f"{self.group_data['name']} ({n})")
def _apply_theme(self):
t = theme.current()
ic = "#cccccc" if theme.name() == "dark" else "#555555"
self.header.setStyleSheet(f"""
QWidget {{ background:{t['header_bg']}; border-radius:5px; }}
QWidget:hover {{ background:{t['header_hover']}; }}
""")
self.title_label.setStyleSheet(
f"color:{t['item_name_color']}; font-size:12px; font-weight:bold; background:transparent;"
)
# 展开/收缩图标
arrow = "fa5s.chevron-down" if not self.collapsed else "fa5s.chevron-right"
self.toggle_icon.setPixmap(qta.icon(arrow, color=ic).pixmap(14, 14))
self._add_btn.setIcon(qta.icon("fa5s.plus", color=ic))
self._add_btn.setIconSize(QSize(11, 11))
self._more_btn.setIcon(qta.icon("fa5s.ellipsis-h", color=ic))
self._more_btn.setIconSize(QSize(11, 11))
self._drag_handle.setPixmap(qta.icon("fa5s.grip-vertical", color=ic).pixmap(14, 14))
path_color = "#777" if theme.name() == "dark" else "#999"
self.path_label.setStyleSheet(
f"font-size:9px; color:{path_color}; background:transparent;"
)
self.flow.apply_theme()
def _toggle(self):
self.collapsed = not self.collapsed
self.flow.setVisible(not self.collapsed)
if not self.collapsed:
self.flow.updateGeometry()
self.updateGeometry()
# 更新箭头图标
t = theme.current()
ic = "#cccccc" if theme.name() == "dark" else "#555555"
arrow = "fa5s.chevron-down" if not self.collapsed else "fa5s.chevron-right"
self.toggle_icon.setPixmap(qta.icon(arrow, color=ic).pixmap(14, 14))
def _add_item_dialog(self):
path, _ = dialog_style.get_open_file_name(
self,
"选择程序或快捷方式",
"",
"程序/快捷方式 (*.exe *.lnk *.bat *.cmd *.url);;所有文件 (*)",
)
if path:
store = shortcut_target.path_for_storage(path)
name = shortcut_target.item_name_from_sources(path, store)
database.add_item(self.group_data["id"], name, store)
self.flow.refresh()
def _group_menu(self):
t = theme.current()
menu = QMenu(self)
menu.setStyleSheet(f"""
QMenu {{ background:{t['menu_bg']}; color:{t['menu_color']};
border:1px solid {t['menu_border']}; padding:4px; border-radius:6px; }}
QMenu::item {{ padding:5px 16px; border-radius:3px; }}
QMenu::item:selected {{ background:{t['menu_selected']}; }}
""")
rename_act = menu.addAction("重命名")
del_act = menu.addAction("删除分组")
action = menu.exec(QCursor.pos())
if action == rename_act:
name, ok = dialog_style.get_text(
self, "重命名", "新名称:", text=self.group_data["name"]
)
if ok and name.strip():
database.rename_group(self.group_data["id"], name.strip())
self.group_data["name"] = name.strip()
self._update_title_count()
elif action == del_act:
ret = dialog_style.question(
self,
"确认",
f"删除分组「{self.group_data['name']}」及其所有程序?",
default_button=QMessageBox.StandardButton.No,
)
if ret == QMessageBox.StandardButton.Yes:
database.delete_group(self.group_data["id"])
self.request_refresh_all.emit()
def refresh(self, keyword=""):
self.flow.refresh(keyword)
def filter(self, keyword: str):
self.flow.filter(keyword)
if keyword and self.collapsed:
self._toggle()