547 lines
21 KiB
Python
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()
|