first commit
This commit is contained in:
@@ -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
@@ -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()
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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}")
|
||||
@@ -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
@@ -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)
|
||||
Reference in New Issue
Block a user