新增统计栏/过滤/预设/深色主题/托盘/DPI适配/发送文件/RTS-DTR,修复配置保存与弹窗置顶

This commit is contained in:
2026-08-11 10:42:16 +08:00
parent b43249fdf8
commit 5a0341699f
3 changed files with 496 additions and 67 deletions
+448 -42
View File
@@ -18,15 +18,15 @@ from datetime import datetime
import serial
import serial.tools.list_ports
from PySide6.QtCore import Qt, QTimer, Signal, QSize, QRect, QPoint
from PySide6.QtGui import QFont, QColor, QTextCharFormat, QTextCursor, QIcon
from PySide6.QtCore import Qt, QTimer, Signal, QSize, QRect, QPoint, QEvent
from PySide6.QtGui import QFont, QColor, QTextCharFormat, QTextCursor, QIcon, QPalette
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout, QHBoxLayout,
QGridLayout, QLabel, QComboBox, QLineEdit, QPushButton, QDialog,
QPlainTextEdit, QCheckBox, QSpinBox, QSplitter, QMessageBox,
QTableWidget, QTableWidgetItem, QListWidget, QListWidgetItem,
QGroupBox, QRadioButton, QHeaderView, QFileDialog, QWidgetItem, QLayout,
QToolBar,
QToolBar, QMenu, QInputDialog, QSystemTrayIcon,
)
STAMP_OPTS = ['', 'ASCII(YYYY-MM-DD HH:MM:SS)', 'ASCII(HH:MM:SS)',
@@ -41,6 +41,16 @@ WIDTH_OPTS = ['1', '2', '4']
ENDIAN_OPTS = ['小端', '大端']
def inherit_topmost(dialog):
"""若主窗口处于置顶状态,则让弹出的对话框也置顶,避免被主窗口遮住"""
p = dialog.parentWidget()
while p is not None:
if getattr(p, '_topmost_active', False):
dialog.setWindowFlag(Qt.WindowStaysOnTopHint, True)
return
p = p.parentWidget()
class FlowLayout(QLayout):
"""可自动换行的流式布局:窗口过窄时控件自动换到下一行,不会显示不全"""
@@ -117,11 +127,30 @@ class FlowLayout(QLayout):
class SendEdit(QPlainTextEdit):
"""回车即发送的发送框,支持↑↓键浏览发送历史"""
"""回车即发送的发送框,支持↑↓键浏览发送历史Ctrl+滚轮调整字号"""
enterPressed = Signal()
histNav = Signal(int)
def __init__(self, parent=None):
super().__init__(parent)
self._font_size = 11
def wheelEvent(self, event):
if event.modifiers() & Qt.ControlModifier:
delta = event.angleDelta().y()
if delta:
self._font_size = max(8, min(40, self._font_size + (1 if delta > 0 else -1)))
font = self.font()
font.setPointSize(self._font_size)
self.setFont(font)
event.accept()
return
super().wheelEvent(event)
def font_size(self):
return self._font_size
def keyPressEvent(self, e):
if e.key() in (Qt.Key_Return, Qt.Key_Enter) and not (e.modifiers() & Qt.ShiftModifier):
self.enterPressed.emit()
@@ -134,25 +163,18 @@ class SendEdit(QPlainTextEdit):
class RecvEdit(QPlainTextEdit):
"""接收区编辑框:Ctrl+滚轮 调整字体大小"""
"""接收区编辑框:Ctrl+滚轮 调整字体大小,右键菜单"""
MIN_FONT = 8
MAX_FONT = 40
DEFAULT_FONT = 11
saveRequested = Signal()
clearRequested = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self._font_size = self.DEFAULT_FONT
def wheelEvent(self, event):
if event.modifiers() & Qt.ControlModifier:
delta = event.angleDelta().y()
if delta:
self.set_font_size(self._font_size + (1 if delta > 0 else -1))
event.accept()
return
super().wheelEvent(event)
def set_font_size(self, size):
self._font_size = max(self.MIN_FONT, min(self.MAX_FONT, int(size)))
font = self.font()
@@ -162,6 +184,26 @@ class RecvEdit(QPlainTextEdit):
def font_size(self):
return self._font_size
def contextMenuEvent(self, event):
menu = self.createStandardContextMenu()
menu.addSeparator()
act_save = menu.addAction('另存为…')
act_clear = menu.addAction('清空')
ch = menu.exec(event.globalPos())
if ch == act_save:
self.saveRequested.emit()
elif ch == act_clear:
self.clearRequested.emit()
def wheelEvent(self, event):
if event.modifiers() & Qt.ControlModifier:
delta = event.angleDelta().y()
if delta:
self.set_font_size(self.font_size() + (1 if delta > 0 else -1))
event.accept()
return
super().wheelEvent(event)
class NoWheelSpinBox(QSpinBox):
def wheelEvent(self, event):
@@ -519,6 +561,7 @@ def write_field(ftype, widget, field):
class RuleEditDialog(QDialog):
def __init__(self, parent=None, rule=None):
super().__init__(parent)
inherit_topmost(self)
self.setWindowTitle('应答规则')
self.setMinimumWidth(800)
v = QVBoxLayout(self)
@@ -673,6 +716,7 @@ class RuleEditDialog(QDialog):
class AutoReplyManagerDialog(QDialog):
def __init__(self, rules, parent=None):
super().__init__(parent)
inherit_topmost(self)
self.setWindowTitle('自动应答规则')
self.setMinimumSize(520, 380)
self.rules = [dict(r) for r in rules]
@@ -784,6 +828,7 @@ class BatchSendDialog(QDialog):
def __init__(self, panel, parent=None):
super().__init__(parent)
inherit_topmost(self)
self.panel = panel
self.setWindowTitle('批量发送')
self.setMinimumSize(560, 360)
@@ -1003,6 +1048,16 @@ class BasePanel(QWidget):
self._log_hour = None
self.log_rotate_timer = QTimer(self)
self.log_rotate_timer.timeout.connect(self._rotate_log)
self._stat_tx = 0
self._stat_rx = 0
self._stat_tx_frames = 0
self._stat_rx_frames = 0
self._filter_enabled = False
self._filter_text = ''
self._append_buffer = []
self._flush_timer = QTimer(self)
self._flush_timer.setSingleShot(True)
self._flush_timer.timeout.connect(self._flush_pending)
self.root = QVBoxLayout(self)
@@ -1021,6 +1076,8 @@ class BasePanel(QWidget):
self.recv.setReadOnly(True)
self.recv.setMaximumBlockCount(2000)
self.recv.setFont(QFont('Consolas', RecvEdit.DEFAULT_FONT))
self.recv.saveRequested.connect(self.save_received_log)
self.recv.clearRequested.connect(self.recv.clear)
rv.addWidget(self.recv, 1)
splitter.addWidget(recv_widget)
@@ -1030,7 +1087,7 @@ class BasePanel(QWidget):
sv.addWidget(QLabel('发送区'))
self.send = SendEdit()
self.send.setMaximumBlockCount(2000)
self.send.setStyleSheet('QPlainTextEdit{font-family:Consolas;font-size:11pt}')
self.send.setFont(QFont('Consolas', 11))
self.send.enterPressed.connect(self.on_enter_pressed)
self.send.histNav.connect(self.history_nav)
sv.addWidget(self.send)
@@ -1069,6 +1126,15 @@ class BasePanel(QWidget):
self.chk_info = QCheckBox('INFO')
self.chk_info.setChecked(True)
hr.addWidget(self.chk_info)
self.chk_filter = QCheckBox('过滤')
self.chk_filter.toggled.connect(self._on_filter_toggled)
self.edit_filter = QLineEdit()
self.edit_filter.setPlaceholderText('关键字')
self.edit_filter.setEnabled(False)
self.edit_filter.setMaximumWidth(130)
self.edit_filter.textChanged.connect(self._on_filter_text)
hr.addWidget(self.chk_filter)
hr.addWidget(self.edit_filter)
self.chk_save_log = QCheckBox('保存日志')
self.chk_save_log.toggled.connect(self.toggle_save_log)
hr.addWidget(self.chk_save_log)
@@ -1134,19 +1200,24 @@ class BasePanel(QWidget):
self.btn_import.clicked.connect(self.import_config)
self.btn_send = QPushButton('发送')
self.btn_batch = QPushButton('批量发送')
self.btn_send_file = QPushButton('发送文件')
self.btn_clear_recv = QPushButton('清空接收')
self.btn_clear_send = QPushButton('清空发送')
self.btn_send.clicked.connect(self.doSend)
self.btn_batch.clicked.connect(self.open_batch)
self.btn_send_file.clicked.connect(self.send_file)
self.btn_clear_recv.clicked.connect(self.recv.clear)
self.btn_clear_send.clicked.connect(self.send.clear)
btn_row.addWidget(self.btn_export)
btn_row.addWidget(self.btn_import)
btn_row.addWidget(self.btn_send)
btn_row.addWidget(self.btn_batch)
btn_row.addWidget(self.btn_send_file)
btn_row.addWidget(self.btn_clear_recv)
btn_row.addWidget(self.btn_clear_send)
btn_row.addStretch()
self.stat_label = QLabel('TX: 0 B / 0 帧 RX: 0 B / 0 帧')
btn_row.addWidget(self.stat_label)
self.root.addLayout(btn_row)
self.setWindowTitle(title)
@@ -1159,7 +1230,7 @@ class BasePanel(QWidget):
self.doSend()
def open_batch(self):
dlg = BatchSendDialog(self)
dlg = BatchSendDialog(self, parent=self)
if self.batch_rows:
dlg.import_config(self.batch_rows)
dlg.exec()
@@ -1188,22 +1259,67 @@ class BasePanel(QWidget):
payload = bytes_to_hex(data)
else:
payload = data.decode('utf-8', 'replace')
if direction == 'TX':
self._stat_tx += len(data)
self._stat_tx_frames += 1
else:
self._stat_rx += len(data)
self._stat_rx_frames += 1
self._update_stats()
if self._filter_enabled and self._filter_text \
and self._filter_text.lower() not in payload.lower():
return
text = f'{ts()}' if self.chk_ts.isChecked() else ''
color = 'blue' if direction == 'TX' else 'green'
self._append_line(f'[{text}] {direction}: {payload}' if text else f'{direction}: {payload}', color)
def _append_colored(self, text: str, color: str):
def _update_stats(self):
self.stat_label.setText(
f'TX: {self._stat_tx} B / {self._stat_tx_frames}'
f'RX: {self._stat_rx} B / {self._stat_rx_frames}')
def _flush_pending(self):
if not self._append_buffer:
return
cur = self.recv.textCursor()
cur.movePosition(QTextCursor.End)
if color:
fmt = QTextCharFormat()
fmt.setForeground(QColor(color))
cur.setCharFormat(fmt)
cur.insertText(text + '\n')
fmt_cache = {}
for text, color in self._append_buffer:
if color not in fmt_cache:
f = QTextCharFormat()
f.setForeground(QColor(color))
fmt_cache[color] = f
cur.setCharFormat(fmt_cache[color])
cur.insertText(text + '\n')
self._append_buffer.clear()
self.recv.setTextCursor(cur)
bar = self.recv.verticalScrollBar()
bar.setValue(bar.maximum())
def _append_colored(self, text: str, color: str):
self._append_buffer.append((text, color))
if not self._flush_timer.isActive():
self._flush_timer.start(120)
def _on_filter_toggled(self, checked: bool):
self._filter_enabled = checked
self.edit_filter.setEnabled(checked)
def _on_filter_text(self, text: str):
self._filter_text = text
def save_received_log(self):
path, _ = QFileDialog.getSaveFileName(self, '保存接收内容', 'recv.txt',
'文本文件 (*.txt);;所有文件 (*)')
if not path:
return
try:
with open(path, 'w', encoding='utf-8') as f:
f.write(self.recv.toPlainText())
self.info(f'接收内容已保存: {path}')
except OSError as e:
self.info(f'保存失败: {e}')
def _append_line(self, text: str, color: str = None):
self._append_colored(text, color)
if self._log_fh or self._log_dir:
@@ -1263,6 +1379,36 @@ class BasePanel(QWidget):
def actual_send(self, data: bytes) -> bool:
raise NotImplementedError
def send_file(self):
path, _ = QFileDialog.getOpenFileName(self, '选择要发送的文件', '')
if not path:
return
try:
with open(path, 'rb') as f:
data = f.read()
except OSError as e:
QMessageBox.warning(self, '错误', f'读取文件失败:\n{e}')
return
if not data:
self.info('文件内容为空,未发送')
return
self._file_total = len(data)
self.info(f'开始发送文件({len(data)} B): {path}')
threading.Thread(target=self._send_file_worker, args=(data,),
daemon=True).start()
def _send_file_worker(self, data: bytes):
sent = 0
for i in range(0, len(data), 4096):
part = data[i:i + 4096]
if not self.actual_send(part):
self.info('发送文件已中止')
return
sent += len(part)
self.log_line('TX', part)
time.sleep(0.01)
self.info(f'文件发送完成,共 {sent} B')
def send_reply(self, reply: bytes, sock=None, addr=None):
raise NotImplementedError
@@ -1389,6 +1535,8 @@ class BasePanel(QWidget):
'display': 'HEX' if self.rb_recv_hex.isChecked() else 'ASCII',
'ts': self.chk_ts.isChecked(),
'info': self.chk_info.isChecked(),
'filter': self._filter_enabled,
'filter_text': self._filter_text,
},
'send': {
'format': 'HEX' if self.rb_send_hex.isChecked() else '文本',
@@ -1396,6 +1544,7 @@ class BasePanel(QWidget):
'crlf': self.chk_crlf.isChecked(),
'auto': self.chk_auto.isChecked(),
'interval': self.spin_interval.value(),
'font_size': self.send.font_size(),
},
'reply': {
'enabled': self.chk_reply.isChecked(),
@@ -1403,6 +1552,7 @@ class BasePanel(QWidget):
'respond': 'HEX' if self.rb_reply_hex.isChecked() else 'ASCII',
},
'font_size': self.recv.font_size(),
'send_history': self.send_history[-200:],
'reply_rules': copy.deepcopy(self.reply_rules),
'batch_rows': copy.deepcopy(self.batch_rows),
}
@@ -1420,6 +1570,11 @@ class BasePanel(QWidget):
self.chk_ts.setChecked(bool(recv['ts']))
if 'info' in recv:
self.chk_info.setChecked(bool(recv['info']))
if 'filter' in recv:
self.chk_filter.setChecked(bool(recv['filter']))
self.edit_filter.setEnabled(bool(recv['filter']))
if 'filter_text' in recv:
self.edit_filter.setText(recv.get('filter_text', ''))
msg.append('接收设置')
send = cfg.get('send')
if isinstance(send, dict):
@@ -1434,6 +1589,16 @@ class BasePanel(QWidget):
self.chk_auto.setChecked(bool(send['auto']))
if 'interval' in send:
self.spin_interval.setValue(int(send['interval']))
if 'font_size' in send:
try:
size = max(8, min(40, int(send['font_size'])))
f = self.send.font()
f.setPointSize(size)
self.send.setFont(f)
if hasattr(self.send, '_font_size'):
self.send._font_size = size
except (TypeError, ValueError):
pass
msg.append('发送设置')
reply = cfg.get('reply')
if isinstance(reply, dict):
@@ -1452,6 +1617,10 @@ class BasePanel(QWidget):
except (TypeError, ValueError):
pass
msg.append('字体大小')
if 'send_history' in cfg and isinstance(cfg['send_history'], list):
self.send_history = [str(x) for x in cfg['send_history']][-200:]
self._hist_idx = len(self.send_history)
msg.append(f'发送历史 {len(self.send_history)}')
rr = cfg.get('reply_rules')
if isinstance(rr, list) and all(isinstance(x, dict) for x in rr):
for r in rr:
@@ -1599,6 +1768,10 @@ class SerialPanel(BasePanel):
self.btn_refresh.clicked.connect(self.refresh_ports)
self.btn_open = QPushButton('打开串口')
self.btn_open.clicked.connect(self.toggle_serial)
self.chk_rts = QCheckBox('RTS')
self.chk_dtr = QCheckBox('DTR')
self.chk_rts.setToolTip('打开串口后控制 RTS 引脚电平')
self.chk_dtr.setToolTip('打开串口后控制 DTR 引脚电平')
g.addWidget(QLabel('串口'), 0, 0)
g.addWidget(self.combox_port, 0, 1)
@@ -1611,7 +1784,9 @@ class SerialPanel(BasePanel):
g.addWidget(self.combox_parity, 1, 4)
g.addWidget(QLabel('停止位'), 2, 0)
g.addWidget(self.combox_stop, 2, 1)
g.addWidget(self.btn_open, 2, 3, 1, 2)
g.addWidget(self.chk_rts, 2, 3)
g.addWidget(self.chk_dtr, 2, 4)
g.addWidget(self.btn_open, 3, 3, 1, 2)
g.setColumnStretch(1, 1)
g.setColumnStretch(4, 1)
w.setLayout(g)
@@ -1646,6 +1821,11 @@ class SerialPanel(BasePanel):
stopbits=STOPBITS[self.combox_stop.currentText()],
timeout=0.1,
)
try:
self.ser.rts = self.chk_rts.isChecked()
self.ser.dtr = self.chk_dtr.isChecked()
except Exception:
pass
self.btn_open.setText('关闭串口')
self.combox_port.setEnabled(False)
self.info(f'串口 {port} 已打开')
@@ -1696,6 +1876,21 @@ class SerialPanel(BasePanel):
QMessageBox.warning(self, '错误', f'发送失败\n{e}')
return False
def collect_config(self):
cfg = super().collect_config()
cfg['rts'] = self.chk_rts.isChecked()
cfg['dtr'] = self.chk_dtr.isChecked()
return cfg
def apply_config(self, cfg):
msg = super().apply_config(cfg)
if isinstance(cfg, dict):
if 'rts' in cfg:
self.chk_rts.setChecked(bool(cfg['rts']))
if 'dtr' in cfg:
self.chk_dtr.setChecked(bool(cfg['dtr']))
return msg
class NetPanel(BasePanel):
"""TCP/UDP 通用面板,支持客户端与服务器模式"""
@@ -2000,13 +2195,47 @@ class MainWindow(QMainWindow):
self.top_btn = QCheckBox('窗口置顶')
self.top_btn.setToolTip('选中后窗口保持在最前端')
self.top_btn.toggled.connect(self._toggle_topmost)
self.chk_dark = QCheckBox('深色模式')
self.chk_dark.setToolTip('切换亮色/暗色主题')
self.chk_dark.toggled.connect(self._toggle_dark)
self.chk_tray = QCheckBox('最小化到托盘')
self.chk_tray.setToolTip('最小化或关闭窗口时隐藏到系统托盘')
self.chk_tray.toggled.connect(self._on_tray_toggled)
tb = QToolBar('选项')
tb.setMovable(False)
tb.addWidget(self.top_btn)
tb.addWidget(self.chk_dark)
tb.addWidget(self.chk_tray)
tb.addSeparator()
self.combo_profile = NoWheelComboBox()
self.combo_profile.setMinimumWidth(150)
self.combo_profile.currentIndexChanged.connect(self._on_profile_selected)
tb.addWidget(QLabel(' 预设 '))
tb.addWidget(self.combo_profile)
btn_psave = QPushButton('保存预设')
btn_pdel = QPushButton('删除预设')
btn_psave.clicked.connect(self._save_profile)
btn_pdel.clicked.connect(self._del_profile)
tb.addWidget(btn_psave)
tb.addWidget(btn_pdel)
self.addToolBar(tb)
self._profiles = {}
self._tray_enabled = False
self._topmost_active = False
self._light_palette = QApplication.instance().palette()
self.tray_icon = None
self._autosave = QTimer(self)
self._autosave.setInterval(30000)
self._autosave.timeout.connect(self._save_app_config)
self._autosave.start()
self._build_tray()
self._load_profiles()
self._load_app_config()
def _toggle_topmost(self, on: bool):
self._topmost_active = on
try:
import ctypes
user32 = ctypes.windll.user32
@@ -2035,28 +2264,94 @@ class MainWindow(QMainWindow):
QTimer.singleShot(0, lambda: (self.setWindowFlag(Qt.WindowStaysOnTopHint, on),
self.show()))
def _save_app_config(self):
data = {'topmost': self.top_btn.isChecked(),
'tabs': {
'serial': self.serial_panel.collect_config(),
'tcp': self.tcp_panel.collect_config(),
'udp': self.udp_panel.collect_config(),
}}
try:
with open(app_config_path(), 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except OSError:
pass
def _toggle_dark(self, on: bool):
app = QApplication.instance()
if on:
p = QPalette()
p.setColor(QPalette.Window, QColor('#2b2b2b'))
p.setColor(QPalette.WindowText, QColor('#f0f0f0'))
p.setColor(QPalette.Base, QColor('#232323'))
p.setColor(QPalette.AlternateBase, QColor('#3c3f41'))
p.setColor(QPalette.Text, QColor('#f0f0f0'))
p.setColor(QPalette.Button, QColor('#3c3f41'))
p.setColor(QPalette.ButtonText, QColor('#f0f0f0'))
p.setColor(QPalette.BrightText, QColor('#ff5555'))
p.setColor(QPalette.Highlight, QColor('#2a82da'))
p.setColor(QPalette.HighlightedText, QColor('#ffffff'))
p.setColor(QPalette.Link, QColor('#2a82da'))
p.setColor(QPalette.ToolTipBase, QColor('#f0f0f0'))
p.setColor(QPalette.ToolTipText, QColor('#2b2b2b'))
p.setColor(QPalette.Disabled, QPalette.Text, QColor('#787878'))
p.setColor(QPalette.Disabled, QPalette.ButtonText, QColor('#787878'))
p.setColor(QPalette.Disabled, QPalette.Base, QColor('#2b2b2b'))
app.setStyle('Fusion')
app.setPalette(p)
else:
app.setStyle('Fusion')
app.setPalette(self._light_palette)
def _load_app_config(self):
try:
with open(app_config_path(), 'r', encoding='utf-8') as f:
data = json.load(f)
except Exception:
def _build_tray(self):
icon = QIcon(self._icon_path())
if icon.isNull():
self.tray_icon = None
return
self.tray_icon = QSystemTrayIcon(icon, self)
menu = QMenu(self)
act_show = menu.addAction('显示主界面')
act_show.triggered.connect(self._show_from_tray)
menu.addSeparator()
act_quit = menu.addAction('退出')
act_quit.triggered.connect(self._quit_app)
self.tray_icon.setContextMenu(menu)
self.tray_icon.activated.connect(self._on_tray_activated)
def _on_tray_toggled(self, checked: bool):
self._tray_enabled = checked
if self.tray_icon:
if checked:
self.tray_icon.show()
else:
self.tray_icon.hide()
def _on_tray_activated(self, reason):
if reason == QSystemTrayIcon.Trigger:
self._show_from_tray()
def _show_from_tray(self):
self.showNormal()
self.show()
self.raise_()
self.activateWindow()
def _quit_app(self):
self._tray_enabled = False
if self.tray_icon:
self.tray_icon.hide()
self.close()
def changeEvent(self, event):
if event.type() == QEvent.WindowStateChange and self.isMinimized() \
and self._tray_enabled and self.tray_icon and self.tray_icon.isVisible():
QTimer.singleShot(0, self.hide)
super().changeEvent(event)
def info(self, text: str):
self.statusBar().showMessage(text, 3000)
def _collect_all_config(self):
return {
'topmost': self.top_btn.isChecked(),
'dark': self.chk_dark.isChecked(),
'tray': self.chk_tray.isChecked(),
'tabs': {
'serial': self.serial_panel.collect_config(),
'tcp': self.tcp_panel.collect_config(),
'udp': self.udp_panel.collect_config(),
},
}
def _apply_panels(self, data):
tabs_cfg = data.get('tabs') if isinstance(data, dict) else None
if isinstance(data, dict) and data.get('topmost'):
self.top_btn.setChecked(True)
if not isinstance(tabs_cfg, dict):
return
for panel, key in ((self.serial_panel, 'serial'),
@@ -2066,12 +2361,123 @@ class MainWindow(QMainWindow):
if isinstance(cfg, dict):
panel.apply_config(cfg)
def _save_app_config(self):
try:
with open(app_config_path(), 'w', encoding='utf-8') as f:
json.dump(self._collect_all_config(), f, ensure_ascii=False, indent=2)
except Exception:
pass
def _load_app_config(self):
try:
with open(app_config_path(), 'r', encoding='utf-8') as f:
data = json.load(f)
except Exception:
return
if not isinstance(data, dict):
return
if data.get('topmost'):
self.top_btn.setChecked(True)
if data.get('dark'):
self.chk_dark.setChecked(True)
if data.get('tray'):
self.chk_tray.setChecked(True)
self._apply_panels(data)
def _profile_path(self):
return os.path.splitext(app_config_path())[0] + '_profiles.json'
def _load_profiles(self):
try:
with open(self._profile_path(), 'r', encoding='utf-8') as f:
data = json.load(f)
self._profiles = data if isinstance(data, dict) else {}
except Exception:
self._profiles = {}
self._reload_profiles_combo()
def _write_profiles(self):
try:
with open(self._profile_path(), 'w', encoding='utf-8') as f:
json.dump(self._profiles, f, ensure_ascii=False, indent=2)
except OSError:
pass
def _reload_profiles_combo(self, select=None):
self.combo_profile.blockSignals(True)
self.combo_profile.clear()
self.combo_profile.addItem('← 选择预设 →')
for name in self._profiles:
self.combo_profile.addItem(name)
select = self.combo_profile.currentText() if select is None else select
idx = self.combo_profile.findText(select)
if idx >= 0 and idx != 0:
self.combo_profile.setCurrentIndex(idx)
self.combo_profile.blockSignals(False)
def _on_profile_selected(self, index):
if index <= 0:
return
name = self.combo_profile.currentText()
cfg = self._profiles.get(name)
if not isinstance(cfg, dict):
return
if cfg.get('topmost'):
self.top_btn.setChecked(True)
else:
self.top_btn.setChecked(False)
if cfg.get('dark'):
self.chk_dark.setChecked(True)
else:
self.chk_dark.setChecked(False)
if cfg.get('tray'):
self.chk_tray.setChecked(True)
else:
self.chk_tray.setChecked(False)
self._apply_panels(cfg)
self._save_app_config()
self.info('预设已加载: ' + name)
def _save_profile(self):
text = self.combo_profile.currentText()
if text == '← 选择预设 →':
text = ''
name, ok = QInputDialog.getText(self, '保存预设', '预设名称:', QLineEdit.Normal, text)
if not ok or not name.strip():
return
self._profiles[name.strip()] = self._collect_all_config()
self._write_profiles()
self._reload_profiles_combo(name.strip())
self.info('预设已保存: ' + name.strip())
def _del_profile(self):
name = self.combo_profile.currentText()
if name == '← 选择预设 →' or name not in self._profiles:
return
if QMessageBox.question(self, '删除预设', f'确定删除预设「{name}」吗?') \
!= QMessageBox.Yes:
return
del self._profiles[name]
self._write_profiles()
self._reload_profiles_combo()
self.info('预设已删除: ' + name)
def closeEvent(self, event):
self._save_app_config()
if self._tray_enabled and self.tray_icon and self.tray_icon.isVisible():
event.ignore()
self.hide()
self.tray_icon.showMessage(APP_NAME, '程序已最小化到托盘',
QSystemTrayIcon.Information, 2000)
return
if self.tray_icon:
self.tray_icon.hide()
super().closeEvent(event)
if __name__ == '__main__':
QApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
app = QApplication(sys.argv)
app.setApplicationName(APP_NAME)
app.setFont(QFont('Microsoft YaHei', 9))