first commit

This commit is contained in:
2026-04-03 21:50:36 +08:00
commit 10843a5bc5
43 changed files with 3958 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# ui package
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+206
View File
@@ -0,0 +1,206 @@
import os
from PyQt6.QtWidgets import QWidget, QApplication
from PyQt6.QtCore import Qt, QPoint, QTimer, pyqtSignal, QPropertyAnimation, QEasingCurve, QRectF
from PyQt6.QtGui import (QPainter, QPixmap, QBrush, QColor, QPen,
QPainterPath, QRadialGradient, QIcon)
BALL_SIZE = 60
LOGO_SIZE = 34
LOGO_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logo.png")
DRAG_THRESHOLD = 6
class FloatBall(QWidget):
clicked = pyqtSignal()
right_clicked = pyqtSignal(QPoint)
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowFlags(
Qt.WindowType.FramelessWindowHint |
Qt.WindowType.WindowStaysOnTopHint |
Qt.WindowType.Tool
)
if os.path.exists(LOGO_PATH):
self.setWindowIcon(QIcon(LOGO_PATH))
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
# 关键:让整个窗口区域都透明,不显示系统边框
self.setFixedSize(BALL_SIZE + 20, BALL_SIZE + 20) # 留出光晕空间
self.setCursor(Qt.CursorShape.PointingHandCursor)
self._logo = QPixmap(LOGO_PATH) if os.path.exists(LOGO_PATH) else QPixmap()
self._drag_start = QPoint()
self._dragging = False
self._hovered = False
self._glow = 0.0 # 0.0~1.0 光晕强度
# 光晕动画
self._glow_anim = QPropertyAnimation(self, b"_glow_prop")
self._glow_anim.setDuration(300)
self._glow_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
# 闲置淡出
self._idle_timer = QTimer(self)
self._idle_timer.setSingleShot(True)
self._idle_timer.setInterval(3000)
self._idle_timer.timeout.connect(self._fade_out)
self._place_default()
self._idle_timer.start()
# Qt property 用于动画
def _get_glow(self): return self._glow
def _set_glow(self, v): self._glow = v; self.update()
from PyQt6.QtCore import pyqtProperty
_glow_prop = pyqtProperty(float, _get_glow, _set_glow)
def _place_default(self):
screen = QApplication.primaryScreen().availableGeometry()
pad = (self.width() - BALL_SIZE) // 2
self.move(screen.right() - self.width() - 20 + pad,
screen.top() + screen.height() // 2 - self.height() // 2)
# ── 绘制 ────────────────────────────────────────────
def paintEvent(self, event):
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
pad = (self.width() - BALL_SIZE) // 2 # 光晕留白
cx = self.width() / 2
cy = self.height() / 2
r = BALL_SIZE / 2 - 1
# 1. 外部光晕(hover 时)
if self._glow > 0:
glow_r = r + 10 * self._glow
grad = QRadialGradient(cx, cy, glow_r)
grad.setColorAt(0, QColor(255, 255, 255, int(80 * self._glow)))
grad.setColorAt(0.5, QColor(255, 255, 255, int(30 * self._glow)))
grad.setColorAt(1, QColor(255, 255, 255, 0))
p.setBrush(QBrush(grad))
p.setPen(Qt.PenStyle.NoPen)
p.drawEllipse(QRectF(cx - glow_r, cy - glow_r, glow_r * 2, glow_r * 2))
# 2. 圆形裁剪
clip = QPainterPath()
clip.addEllipse(QRectF(cx - r, cy - r, r * 2, r * 2))
p.setClipPath(clip)
# 3. 白色半透明毛玻璃底
p.setBrush(QBrush(QColor(255, 255, 255, 210)))
p.setPen(Qt.PenStyle.NoPen)
p.drawEllipse(QRectF(cx - r, cy - r, r * 2, r * 2))
# 4. 顶部高光
hi = QRadialGradient(cx, cy - r * 0.3, r * 0.9)
hi.setColorAt(0, QColor(255, 255, 255, 140))
hi.setColorAt(1, QColor(255, 255, 255, 0))
p.setBrush(QBrush(hi))
p.drawEllipse(QRectF(cx - r, cy - r, r * 2, r * 2))
# 5. logo 居中
if not self._logo.isNull():
scaled = self._logo.scaled(
LOGO_SIZE, LOGO_SIZE,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
lx = int(cx - scaled.width() / 2)
ly = int(cy - scaled.height() / 2)
p.drawPixmap(lx, ly, scaled)
# 6. 边框(取消裁剪后画)
p.setClipping(False)
p.setBrush(Qt.BrushStyle.NoBrush)
p.setPen(QPen(QColor(200, 200, 200, 120), 1.5))
p.drawEllipse(QRectF(cx - r, cy - r, r * 2, r * 2))
# 7. hover 时内圈白色光晕边
if self._glow > 0:
p.setPen(QPen(QColor(255, 255, 255, int(160 * self._glow)), 2))
p.drawEllipse(QRectF(cx - r + 1, cy - r + 1, (r - 1) * 2, (r - 1) * 2))
p.end()
# ── 鼠标事件 ────────────────────────────────────────
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._drag_start = event.globalPosition().toPoint()
self._dragging = False
self.setWindowOpacity(1.0)
self._idle_timer.stop()
elif event.button() == Qt.MouseButton.RightButton:
self.right_clicked.emit(event.globalPosition().toPoint())
def mouseMoveEvent(self, event):
if not (event.buttons() & Qt.MouseButton.LeftButton):
return
delta = event.globalPosition().toPoint() - self._drag_start
if not self._dragging and delta.manhattanLength() > DRAG_THRESHOLD:
self._dragging = True
if self._dragging:
new_pos = self.pos() + delta
self._drag_start = event.globalPosition().toPoint()
screen = QApplication.primaryScreen().availableGeometry()
new_pos.setX(max(screen.left(), min(new_pos.x(), screen.right() - self.width())))
new_pos.setY(max(screen.top(), min(new_pos.y(), screen.bottom() - self.height())))
self.move(new_pos)
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
if not self._dragging:
self.clicked.emit()
else:
self._snap_to_edge()
self._idle_timer.start()
def enterEvent(self, event):
self._hovered = True
self.setWindowOpacity(1.0)
self._idle_timer.stop()
self._glow_anim.stop()
self._glow_anim.setStartValue(self._glow)
self._glow_anim.setEndValue(1.0)
self._glow_anim.start()
def leaveEvent(self, event):
self._hovered = False
self._glow_anim.stop()
self._glow_anim.setStartValue(self._glow)
self._glow_anim.setEndValue(0.0)
self._glow_anim.start()
self._idle_timer.start()
# ── 吸附边缘 ─────────────────────────────────────────
def _snap_to_edge(self):
screen = QApplication.primaryScreen().availableGeometry()
cx = self.x() + self.width() // 2
cy = self.y() + self.height() // 2
dists = {
"l": cx - screen.left(),
"r": screen.right() - cx,
"t": cy - screen.top(),
"b": screen.bottom() - cy,
}
side = min(dists, key=dists.get)
ends = {
"l": QPoint(screen.left(), self.y()),
"r": QPoint(screen.right() - self.width(), self.y()),
"t": QPoint(self.x(), screen.top()),
"b": QPoint(self.x(), screen.bottom() - self.height()),
}
anim = QPropertyAnimation(self, b"pos")
anim.setDuration(200)
anim.setEasingCurve(QEasingCurve.Type.OutCubic)
anim.setStartValue(self.pos())
anim.setEndValue(ends[side])
self._snap_anim = anim
anim.start()
def _fade_out(self):
self._anim_fade = QPropertyAnimation(self, b"windowOpacity")
self._anim_fade.setDuration(600)
self._anim_fade.setStartValue(1.0)
self._anim_fade.setEndValue(0.3)
self._anim_fade.start()
+222
View File
@@ -0,0 +1,222 @@
"""标准对话框(QMessageBox / QInputDialog / QFileDialog)与当前主题一致。"""
from __future__ import annotations
from PyQt6.QtWidgets import (
QFileDialog,
QInputDialog,
QMessageBox,
QWidget,
QDialog,
)
import ui.theme as theme
def stylesheet() -> str:
t = theme.current()
bg = t["menu_bg"]
fg = t["menu_color"]
border = t["menu_border"]
hover = t["menu_selected"]
inp = t["search_bg"]
inp_b = t["search_border"]
focus = t["search_focus"]
return f"""
QMessageBox {{
background-color: {bg};
color: {fg};
}}
QMessageBox QLabel {{
color: {fg};
background: transparent;
min-width: 160px;
}}
QMessageBox QPushButton {{
min-width: 64px;
padding: 6px 14px;
border: 1px solid {border};
border-radius: 5px;
background: {inp};
color: {fg};
}}
QMessageBox QPushButton:hover {{
background: {hover};
}}
QMessageBox QPushButton:default {{
border-color: {focus};
}}
QInputDialog {{
background-color: {bg};
color: {fg};
}}
QInputDialog QLabel {{
color: {fg};
background: transparent;
}}
QInputDialog QLineEdit {{
background: {inp};
border: 1px solid {inp_b};
border-radius: 5px;
padding: 6px 8px;
color: {fg};
min-width: 260px;
}}
QInputDialog QLineEdit:focus {{
border-color: {focus};
}}
QInputDialog QPushButton {{
min-width: 72px;
padding: 6px 14px;
border: 1px solid {border};
border-radius: 5px;
background: {inp};
color: {fg};
}}
QInputDialog QPushButton:hover {{
background: {hover};
}}
QFileDialog {{
background-color: {bg};
color: {fg};
}}
QFileDialog QLabel {{
color: {fg};
background: transparent;
}}
QFileDialog QLineEdit, QFileDialog QComboBox {{
background: {inp};
border: 1px solid {inp_b};
border-radius: 4px;
padding: 4px 6px;
color: {fg};
}}
QFileDialog QLineEdit:focus, QFileDialog QComboBox:focus {{
border-color: {focus};
}}
QFileDialog QTreeView, QFileDialog QListView, QFileDialog QTableView {{
background: {inp};
border: 1px solid {inp_b};
border-radius: 4px;
color: {fg};
outline: none;
}}
QFileDialog QTreeView::item:selected, QFileDialog QListView::item:selected {{
background: {hover};
}}
QFileDialog QPushButton {{
min-width: 72px;
padding: 6px 12px;
border: 1px solid {border};
border-radius: 5px;
background: {inp};
color: {fg};
}}
QFileDialog QPushButton:hover {{
background: {hover};
}}
QFileDialog QComboBox QAbstractItemView {{
background: {bg};
color: {fg};
border: 1px solid {border};
}}
"""
def _apply(w: QWidget | None) -> None:
if w is not None:
w.setStyleSheet(stylesheet())
def question(
parent: QWidget | None,
title: str,
text: str,
*,
buttons: QMessageBox.StandardButton = (
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
),
default_button: QMessageBox.StandardButton | None = None,
icon: QMessageBox.Icon = QMessageBox.Icon.Question,
) -> QMessageBox.StandardButton:
msg = QMessageBox(parent)
msg.setWindowTitle(title)
msg.setText(text)
msg.setIcon(icon)
msg.setStandardButtons(buttons)
if default_button is not None:
msg.setDefaultButton(default_button)
_apply(msg)
# 防止对话框因为文本长度/默认最小宽度变得过宽
msg.setFixedWidth(320)
return QMessageBox.StandardButton(msg.exec())
def warning(parent: QWidget | None, title: str, text: str) -> None:
msg = QMessageBox(parent)
msg.setWindowTitle(title)
msg.setText(text)
msg.setIcon(QMessageBox.Icon.Warning)
msg.setStandardButtons(QMessageBox.StandardButton.Ok)
_apply(msg)
msg.setFixedWidth(320)
msg.exec()
def information(parent: QWidget | None, title: str, text: str) -> None:
msg = QMessageBox(parent)
msg.setWindowTitle(title)
msg.setText(text)
msg.setIcon(QMessageBox.Icon.Information)
msg.setStandardButtons(QMessageBox.StandardButton.Ok)
_apply(msg)
msg.setFixedWidth(320)
msg.exec()
def get_text(
parent: QWidget | None,
title: str,
label: str,
text: str = "",
) -> tuple[str, bool]:
d = QInputDialog(parent)
d.setWindowTitle(title)
d.setLabelText(label)
d.setTextValue(text)
_apply(d)
ok = d.exec() == QDialog.DialogCode.Accepted
return d.textValue(), ok
def get_open_file_name(
parent: QWidget | None,
caption: str,
directory: str,
filter_str: str,
) -> tuple[str, str]:
fd = QFileDialog(parent, caption, directory, filter_str)
fd.setOption(QFileDialog.Option.DontUseNativeDialog, True)
fd.setFileMode(QFileDialog.FileMode.ExistingFile)
_apply(fd)
if fd.exec() == QDialog.DialogCode.Accepted:
files = fd.selectedFiles()
if files:
return files[0], fd.selectedNameFilter()
return "", ""
def get_existing_directory(
parent: QWidget | None,
caption: str,
directory: str = "",
) -> str:
fd = QFileDialog(parent, caption, directory)
fd.setOption(QFileDialog.Option.DontUseNativeDialog, True)
fd.setFileMode(QFileDialog.FileMode.Directory)
fd.setOption(QFileDialog.Option.ShowDirsOnly, True)
_apply(fd)
if fd.exec() == QDialog.DialogCode.Accepted:
files = fd.selectedFiles()
if files:
return files[0]
return ""
+1365
View File
File diff suppressed because it is too large Load Diff
+191
View File
@@ -0,0 +1,191 @@
from PyQt6.QtWidgets import QWidget
from PyQt6.QtCore import QPoint, QTimer, Qt, QRect, QSize
from PyQt6.QtGui import QPainter, QColor, QPen
ITEM_W = 72
ITEM_H = 80
GAP = 4
RUBBER_MIN = 4 # 小于此尺寸的框选视为误触
class FlowContainer(QWidget):
"""手动绝对定位的流式容器,完全绕开 QLayout。"""
def __init__(self, parent=None):
super().__init__(parent)
self._widgets: list[QWidget] = []
self._pending = False
self._drag_highlight = False
self.setMouseTracking(True)
self._rubber_active = False
self._rubber_origin = QPoint()
self._rubber_rect: QRect | None = None
def add_widget(self, w: QWidget):
w.setParent(self)
self._widgets.append(w)
if self.width() > 0:
self._relayout()
else:
self._schedule_relayout()
def clear_widgets(self):
for w in self._widgets:
w.hide()
w.setParent(None)
self._widgets.clear()
self.setFixedHeight(60)
def get_widgets(self) -> list:
return list(self._widgets)
def set_drag_highlight(self, active: bool):
if self._drag_highlight != active:
self._drag_highlight = active
self.update()
def showEvent(self, event):
super().showEvent(event)
self._schedule_relayout()
def resizeEvent(self, event):
self._relayout()
super().resizeEvent(event)
def _schedule_relayout(self):
if not self._pending:
self._pending = True
QTimer.singleShot(0, self._delayed_relayout)
def _delayed_relayout(self):
self._pending = False
self._relayout()
def _relayout(self):
avail_w = self.width()
if avail_w <= 0:
p = self.parent()
while p:
if p.width() > 0:
avail_w = p.width() - 16
break
p = p.parent()
if avail_w <= 0:
self._schedule_relayout()
return
x, y = GAP, GAP
row_h = 0
for w in self._widgets:
if not w.isVisible():
w.move(-9999, -9999)
continue
iw = w.width() if w.width() > 0 else ITEM_W
ih = w.height() if w.height() > 0 else ITEM_H
if row_h > 0 and x + iw + GAP > avail_w:
x = GAP
y += row_h + GAP
row_h = 0
w.move(x, y)
x += iw + GAP
row_h = max(row_h, ih)
visible = [w for w in self._widgets if w.isVisible()]
total_h = (y + row_h + GAP) if visible else 60
self.setFixedHeight(max(total_h, 0))
def index_at(self, pos: QPoint) -> int:
"""拖拽插入位置:与流式布局顺序一致,同行每个图标单独判断,不再只认行内第一个。"""
visible = [w for w in self._widgets if w.isVisible()]
if not visible:
return 0
if pos.y() < visible[0].geometry().top():
return 0
for w in visible:
g = w.geometry()
if pos.y() < g.top():
return self._widgets.index(w)
if g.top() <= pos.y() <= g.bottom():
if pos.x() < g.left():
return self._widgets.index(w)
if pos.x() <= g.right():
mid = (g.left() + g.right()) // 2
if pos.x() < mid:
return self._widgets.index(w)
return self._widgets.index(w) + 1
# 落在该行、但在本图标右侧,继续看下一个(同行或下一行)
return len(self._widgets)
def _clear_selection_except(self, keep):
for w in self._widgets:
if w is not keep and hasattr(w, "_set_selected"):
w._set_selected(False)
def clear_selection(self):
self._clear_selection_except(None)
def _begin_rubber(self, pos: QPoint):
self.clear_selection()
self._rubber_active = True
self._rubber_origin = pos
self._rubber_rect = QRect(pos, QSize())
self.grabMouse()
self.update()
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
ch = self.childAt(event.pos())
if ch is None:
self._begin_rubber(event.pos())
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if self._rubber_active:
self._rubber_rect = QRect(self._rubber_origin, event.pos()).normalized()
self.update()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if self._rubber_active and event.button() == Qt.MouseButton.LeftButton:
self._rubber_active = False
self.releaseMouse()
r = self._rubber_rect
self._rubber_rect = None
self.update()
if r is not None and r.width() >= RUBBER_MIN and r.height() >= RUBBER_MIN:
self.clear_selection()
for w in self._widgets:
if not w.isVisible():
continue
if hasattr(w, "_set_selected") and r.intersects(w.geometry()):
w._set_selected(True)
return
super().mouseReleaseEvent(event)
def paintEvent(self, event):
super().paintEvent(event)
need_drag = self._drag_highlight
need_rubber = self._rubber_rect is not None and self._rubber_active
if not need_drag and not need_rubber:
return
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
if need_drag:
pen = QPen(QColor("#4a9eff"), 2, Qt.PenStyle.DashLine)
pen.setDashPattern([6, 4])
p.setPen(pen)
p.setBrush(QColor(74, 158, 255, 25))
p.drawRoundedRect(2, 2, self.width() - 4, self.height() - 4, 6, 6)
if need_rubber:
pen = QPen(QColor("#4a9eff"), 1, Qt.PenStyle.DashLine)
p.setPen(pen)
p.setBrush(QColor(74, 158, 255, 40))
p.drawRoundedRect(self._rubber_rect, 2, 2)
p.end()
+546
View File
@@ -0,0 +1,546 @@
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()
+389
View File
@@ -0,0 +1,389 @@
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}")
+466
View File
@@ -0,0 +1,466 @@
import sys
import os
from PyQt6.QtWidgets import (
QDialog, QHBoxLayout, QVBoxLayout, QListWidget, QListWidgetItem,
QStackedWidget, QWidget, QLabel, QSlider, QPushButton,
QCheckBox, QGroupBox, QFormLayout, QSpinBox, QFrame
)
from PyQt6.QtCore import Qt, QSize
from PyQt6.QtGui import QIcon, QPixmap
import qtawesome as qta
from db import database
import ui.theme as theme
class SettingsWindow(QDialog):
def __init__(self, panel, parent=None):
super().__init__(parent)
self._panel = panel
self.setWindowTitle("设置")
# 适当放大:让页面内容更舒展,并限制最小拖拽尺寸
self.setMinimumSize(500, 500)
self.resize(760, 520)
self.setWindowFlags(
Qt.WindowType.Dialog |
Qt.WindowType.WindowCloseButtonHint
)
self._build_ui()
self._apply_theme()
def _build_ui(self):
root = QHBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
# ── 左侧导航 ─────────────────────────────────────
self.nav = QListWidget()
self.nav.setFixedWidth(130)
self.nav.setSpacing(2)
self.nav.setIconSize(QSize(18, 18))
self.nav.setStyleSheet("""
QListWidget { border:none; outline:none; padding:8px 4px; }
QListWidget::item { height:36px; border-radius:6px;
padding-left:10px; font-size:13px; }
QListWidget::item:selected { background:#4a9eff; color:white; }
QListWidget::item:hover:!selected { background:rgba(128,128,128,40); }
""")
pages = [
("fa5s.paint-brush", "外观"),
("fa5s.window-maximize", "窗口"),
("fa5s.rocket", "启动"),
("fa5s.heart", "捐赠"),
]
for icon_name, label in pages:
try:
icon = qta.icon(icon_name, color="#888")
item = QListWidgetItem(icon, label)
except Exception:
# 防止图标名不存在导致设置页直接报错
icon = qta.icon("fa5s.rocket", color="#888")
item = QListWidgetItem(icon, label)
self.nav.addItem(item)
self.nav.setCurrentRow(0)
self.nav.currentRowChanged.connect(self._on_nav)
# ── 右侧内容区 ───────────────────────────────────
self.stack = QStackedWidget()
self.stack.addWidget(self._page_appearance())
self.stack.addWidget(self._page_window())
self.stack.addWidget(self._page_startup())
self.stack.addWidget(self._page_donate())
# 分割线
line = QFrame()
line.setFrameShape(QFrame.Shape.VLine)
line.setStyleSheet("color: rgba(128,128,128,40);")
root.addWidget(self.nav)
root.addWidget(line)
root.addWidget(self.stack)
# ── 外观页 ───────────────────────────────────────────
def _page_appearance(self) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(24, 20, 24, 20)
layout.setSpacing(20)
layout.addWidget(self._section_title("主题"))
theme_row = QHBoxLayout()
theme_row.setSpacing(12)
self._dark_btn = self._theme_card("🌙 暗色", "dark")
self._light_btn = self._theme_card("☀️ 亮色", "light")
self._dark_btn.clicked.connect(lambda: self._set_theme("dark"))
self._light_btn.clicked.connect(lambda: self._set_theme("light"))
theme_row.addWidget(self._dark_btn)
theme_row.addWidget(self._light_btn)
theme_row.addStretch()
layout.addLayout(theme_row)
self._update_theme_cards()
layout.addWidget(self._divider())
layout.addWidget(self._section_title("透明度"))
opacity_row = QHBoxLayout()
opacity_row.setSpacing(12)
self._opacity_slider = QSlider(Qt.Orientation.Horizontal)
self._opacity_slider.setRange(30, 100)
saved = int(database.get_setting("panel_opacity", "100"))
self._opacity_slider.setValue(saved)
self._opacity_slider.setFixedWidth(220)
self._opacity_val = QLabel(f"{saved}%")
self._opacity_val.setFixedWidth(36)
self._opacity_slider.valueChanged.connect(self._on_opacity)
opacity_row.addWidget(self._opacity_slider)
opacity_row.addWidget(self._opacity_val)
opacity_row.addStretch()
layout.addLayout(opacity_row)
layout.addStretch()
return page
def _theme_card(self, text: str, theme_name: str) -> QPushButton:
btn = QPushButton(text)
btn.setFixedSize(110, 44)
btn.setCheckable(True)
btn.setProperty("theme_name", theme_name)
return btn
def _update_theme_cards(self):
cur = theme.name()
for btn in (self._dark_btn, self._light_btn):
active = btn.property("theme_name") == cur
btn.setChecked(active)
if active:
btn.setStyleSheet("""
QPushButton { background:#4a9eff; color:white;
border-radius:8px; font-size:13px;
border:2px solid #4a9eff; }
""")
else:
t = theme.current()
btn.setStyleSheet(f"""
QPushButton {{ background:{t['search_bg']}; color:{t['search_color']};
border-radius:8px; font-size:13px;
border:1px solid {t['search_border']}; }}
QPushButton:hover {{ border-color:#4a9eff; }}
""")
def _set_theme(self, name: str):
theme.set_theme(name)
self._panel._apply_theme()
self._update_theme_cards()
self._apply_theme()
def _on_opacity(self, val: int):
self._panel.setWindowOpacity(val / 100)
self._opacity_val.setText(f"{val}%")
database.set_setting("panel_opacity", str(val))
# ── 窗口页 ───────────────────────────────────────────
def _page_window(self) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(24, 20, 24, 20)
layout.setSpacing(16)
layout.addWidget(self._section_title("窗口行为"))
self._pin_check = QCheckBox("固定面板(不自动隐藏)")
self._pin_check.setChecked(self._panel._pinned)
self._pin_check.toggled.connect(self._on_pin)
layout.addWidget(self._pin_check)
layout.addWidget(self._divider())
layout.addWidget(self._section_title("图标大小"))
size_row = QHBoxLayout()
size_row.setSpacing(12)
size_lbl = QLabel("图标宽度:")
self._icon_size_spin = QSpinBox()
self._icon_size_spin.setRange(48, 120)
self._icon_size_spin.setSingleStep(4)
saved_size = int(database.get_setting("icon_width", "72"))
self._icon_size_spin.setValue(saved_size)
self._icon_size_spin.setSuffix(" px")
self._icon_size_spin.setFixedWidth(90)
apply_btn = QPushButton("应用")
apply_btn.setFixedWidth(60)
apply_btn.clicked.connect(self._on_icon_size)
size_row.addWidget(size_lbl)
size_row.addWidget(self._icon_size_spin)
size_row.addWidget(apply_btn)
size_row.addStretch()
layout.addLayout(size_row)
layout.addStretch()
return page
def _on_pin(self, checked: bool):
self._panel._pinned = checked
ic = "#cccccc" if theme.name() == "dark" else "#555555"
import qtawesome as qta
self._panel.pin_btn.setIcon(
qta.icon("fa5s.thumbtack", color="#f90" if checked else ic)
)
def _on_icon_size(self):
val = self._icon_size_spin.value()
database.set_setting("icon_width", str(val))
# 通知所有 ItemWidget 更新尺寸
from ui.flow_layout import ITEM_W
import ui.flow_layout as fl
fl.ITEM_W = val
self._panel.refresh_groups()
# ── 启动页 ───────────────────────────────────────────
def _page_startup(self) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(24, 20, 24, 20)
layout.setSpacing(16)
layout.addWidget(self._section_title("开机启动"))
self._autostart_check = QCheckBox("开机自动启动")
self._autostart_check.setChecked(self._is_autostart_enabled())
self._autostart_check.toggled.connect(self._on_autostart)
layout.addWidget(self._autostart_check)
layout.addWidget(self._divider())
layout.addWidget(self._section_title("关于"))
about = QLabel("桌面文件整理 v1.0\n整理你的桌面快捷方式,保持桌面干净。")
about.setStyleSheet("color:#888; font-size:12px; line-height:1.6;")
layout.addWidget(about)
layout.addStretch()
return page
# ── 捐赠页 ───────────────────────────────────────────
def _page_donate(self) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(24, 20, 24, 20)
layout.setSpacing(16)
layout.addWidget(self._section_title("捐赠支持"))
info = QLabel("如果你喜欢这个软件,欢迎通过二维码支持开发者持续优化与维护。你的鼓励会成为重要的动力。<br>感谢你的支持!")
info.setWordWrap(True)
info.setStyleSheet("font-size:14px; line-height:1.1; background:transparent;")
layout.addWidget(info)
layout.addWidget(self._divider())
card = QFrame()
card.setObjectName("donate_card")
grid = QHBoxLayout(card)
grid.setContentsMargins(14, 14, 14, 14)
grid.setSpacing(18)
donate_dir = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "assets", "imgs", "donate"
)
wx_path = os.path.join(donate_dir, "wx.jpg")
zfb_path = os.path.join(donate_dir, "zfb.jpg")
def _qr_column(img_path: str, caption: str) -> QVBoxLayout:
col = QVBoxLayout()
col.setContentsMargins(0, 0, 0, 0)
col.setSpacing(8)
img = QLabel()
img.setFixedSize(170, 170)
img.setAlignment(Qt.AlignmentFlag.AlignCenter)
img.setStyleSheet("background:transparent; border-radius:12px;")
pm = QPixmap(img_path)
if not pm.isNull():
img.setPixmap(
pm.scaled(
img.size(),
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
)
img.setCursor(Qt.CursorShape.PointingHandCursor)
img.setToolTip("点击放大")
# 直接覆盖点击事件:点击二维码弹出大图
img.mousePressEvent = ( # type: ignore[assignment]
lambda e, p=img_path: self._open_donate_image(p)
)
cap = QLabel(caption)
cap.setAlignment(Qt.AlignmentFlag.AlignCenter)
cap.setStyleSheet("font-size:12px; font-weight:700; background:transparent;")
col.addWidget(img)
col.addWidget(cap)
return col
grid.addLayout(_qr_column(wx_path, "微信支持"))
grid.addLayout(_qr_column(zfb_path, "支付宝支持"))
layout.addWidget(card)
# 供 _apply_theme 动态设置样式
self._donate_card = card
return page
def _open_donate_image(self, img_path: str):
"""点击二维码后的弹窗大图查看。"""
t = theme.current()
d = QDialog(self)
d.setWindowTitle("二维码放大查看")
d.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowCloseButtonHint)
d.setFixedSize(800, 600)
d.setStyleSheet(f"""
QDialog {{
background: {t['panel_bg']};
border: 1px solid {t['panel_border']};
border-radius: 10px;
color: {t['search_color']};
}}
""")
layout = QVBoxLayout(d)
layout.setContentsMargins(14, 14, 14, 14)
layout.setSpacing(0)
lbl = QLabel()
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
lbl.setStyleSheet("background:transparent;")
lbl.setScaledContents(False) # 不拉伸,保持等比
pm = QPixmap(img_path)
if not pm.isNull():
# 适配框体:保持等比缩放(不会被拉伸)
target_w = d.width() - 28
target_h = d.height() - 28
lbl.setPixmap(
pm.scaled(
target_w,
target_h,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
)
layout.addWidget(lbl)
d.exec()
def _is_autostart_enabled(self) -> bool:
try:
import winreg
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run",
0, winreg.KEY_READ
)
winreg.QueryValueEx(key, "DesktopOrganizer")
winreg.CloseKey(key)
return True
except Exception:
return False
def _on_autostart(self, checked: bool):
from ui.dock import _set_autostart
_set_autostart(checked)
# ── 工具方法 ─────────────────────────────────────────
def _section_title(self, text: str) -> QLabel:
lbl = QLabel(text)
lbl.setStyleSheet("font-size:14px; font-weight:bold;")
return lbl
def _divider(self) -> QFrame:
line = QFrame()
line.setFrameShape(QFrame.Shape.HLine)
line.setStyleSheet("color: rgba(128,128,128,40);")
return line
def _on_nav(self, index: int):
self.stack.setCurrentIndex(index)
def _apply_theme(self):
t = theme.current()
is_dark = theme.name() == "dark"
# 把 rgba(r,g,b,a) 转成 rgb(r,g,b)
panel_bg = t["panel_bg"]
if panel_bg.startswith("rgba"):
tmp = panel_bg.replace("rgba", "rgb")
# "rgb(r,g,b,a)" -> 取前三项 "rgb(r,g,b"
head = tmp.rsplit(",", 1)[0]
panel_rgb = head + ")"
else:
panel_rgb = panel_bg
self.setStyleSheet(f"""
QDialog, QWidget {{
background: {panel_rgb};
color: {t['search_color']};
}}
QListWidget {{
background: {'rgba(0,0,0,30)' if is_dark else 'rgba(0,0,0,6)'};
}}
QSlider::groove:horizontal {{
height:4px; background:{t['scrollbar']}; border-radius:2px;
}}
QSlider::handle:horizontal {{
width:14px; height:14px; margin:-5px 0;
background:#4a9eff; border-radius:7px;
}}
QSlider::sub-page:horizontal {{
background:#4a9eff; border-radius:2px;
}}
QCheckBox {{ font-size:13px; spacing:8px; }}
QCheckBox::indicator {{
width:18px; height:18px; border-radius:4px;
border:1px solid {t['search_border']};
background:{t['search_bg']};
}}
QCheckBox::indicator:checked {{
background:#4a9eff; border-color:#4a9eff;
image: url(none);
}}
QSpinBox {{
background:{t['search_bg']}; color:{t['search_color']};
border:1px solid {t['search_border']}; border-radius:5px;
padding:4px 8px; font-size:12px;
}}
QPushButton {{
background:{t['search_bg']}; color:{t['search_color']};
border:1px solid {t['search_border']}; border-radius:6px;
padding:6px 14px; font-size:12px;
}}
QPushButton:hover {{ border-color:#4a9eff; color:#4a9eff; }}
""")
# 导航背景
nav_bg = "rgba(0,0,0,30)" if is_dark else "rgba(0,0,0,6)"
self.nav.setStyleSheet(f"""
QListWidget {{ border:none; outline:none; padding:8px 4px;
background:{nav_bg}; }}
QListWidget::item {{ height:36px; border-radius:6px;
padding-left:10px; font-size:13px;
color:{t['search_color']}; }}
QListWidget::item:selected {{ background:#4a9eff; color:white; }}
QListWidget::item:hover:!selected {{
background:{'rgba(255,255,255,10)' if is_dark else 'rgba(0,0,0,6)'}; }}
""")
self._update_theme_cards()
# 捐赠卡片样式
if hasattr(self, "_donate_card"):
card_bg = "rgba(0,0,0,25)" if is_dark else "rgba(255,255,255,10)"
self._donate_card.setStyleSheet(f"""
QFrame#donate_card {{
background:{card_bg};
border: 1px solid {t['panel_border']};
border-radius:14px;
}}
""")
+64
View File
@@ -0,0 +1,64 @@
from db.database import get_setting, set_setting
THEMES = {
"dark": {
"panel_bg": "rgba(28,28,28,242)",
"panel_border": "rgba(255,255,255,18)",
"header_bg": "#3a3a3a",
"header_hover": "#484848",
"item_name_color":"#ddd",
"search_bg": "rgba(255,255,255,12)",
"search_border": "#555",
"search_color": "#eee",
"search_focus": "#4a9eff",
"btn_color": "#999",
"btn_hover": "#fff",
"scrollbar": "#555",
"menu_bg": "#2b2b2b",
"menu_color": "#eee",
"menu_border": "#555",
"menu_selected": "#3a3a3a",
},
"light": {
"panel_bg": "rgba(245,245,245,250)",
"panel_border": "rgba(0,0,0,15)",
"header_bg": "#e0e0e0",
"header_hover": "#d0d0d0",
"item_name_color":"#222",
"search_bg": "rgba(0,0,0,8)",
"search_border": "#bbb",
"search_color": "#222",
"search_focus": "#4a9eff",
"btn_color": "#555",
"btn_hover": "#000",
"scrollbar": "#bbb",
"menu_bg": "#f5f5f5",
"menu_color": "#222",
"menu_border": "#ccc",
"menu_selected": "#e0e0e0",
},
}
_current = "dark"
def load():
"""从数据库读取上次保存的主题,应在 init_db() 之后调用"""
global _current
saved = get_setting("theme", "dark")
_current = saved if saved in THEMES else "dark"
def current() -> dict:
return THEMES[_current]
def name() -> str:
return _current
def set_theme(theme_name: str):
global _current
if theme_name in THEMES:
_current = theme_name
set_setting("theme", theme_name)