diff --git a/.gitignore b/.gitignore index 0c2d952..031cb50 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,8 @@ decompiled_network_debugger.py network_debugger_repaired.py clean_consts.txt mod_doc.txt -docs.json \ No newline at end of file +docs.json + +# 运行时生成的应用配置 +app_config.json +*_profiles.json \ No newline at end of file diff --git a/MODIFICATIONS.md b/MODIFICATIONS.md index 4098606..e4071c0 100644 --- a/MODIFICATIONS.md +++ b/MODIFICATIONS.md @@ -1,31 +1,50 @@ # 修改事项 -以下为本次相对于 e9b3100(初始提交)的改动记录。 +以下为相对于上一提交(b43249f)的改动记录。 -## 1. 接收区字体大小:Ctrl + 鼠标滚轮调整 -- 新增 `RecvEdit` 类(继承 `QPlainTextEdit`),重写 `wheelEvent`: - 按住 **Ctrl + 滚轮** 即可放大/缩小接收区字体(范围 8~40pt)。 -- 接收区改用 `RecvEdit` 并通过 `QFont` 设置字体,替换原来的 stylesheet 方式。 +## 1. 收发统计栏 +- 底部新增统计标签,实时显示 **TX/RX 的字节数与帧数**。 -## 2. 导出/导入配置扩展 -- `export_config` 现在经 `collect_config` 导出以下内容: - - 接收设置:显示格式(ASCII/HEX)、时间戳、INFO 勾选; - - 发送设置:内容格式(文本/HEX)、回车发送、回车换行、定时发送及间隔; - - 应答设置:自动应答、解析格式、回应格式勾选; - - 字体大小、应答规则、批量发送。 -- **保存日志(chk_save_log)不参与导出/导入**,避免日志目录误恢复。 -- `import_config` 通过 `apply_config` 统一恢复上述设置,兼容旧的 `reply_rules` / `batch_rows` 配置文件格式。 +## 2. 接收区右键菜单 +- 新增 `RecvEdit.contextMenuEvent`:复制、全选、**另存为**、清空。 +- "另存为"将接收区全部内容写入文本文件。 -## 3. 关闭自动保存、重启自动恢复配置 -- `MainWindow` 持有串口/TCP/UDP 三个面板引用。 -- `closeEvent` 时把各面板配置写入同目录 `app_config.json`。 -- 启动时自动读取并恢复,无需每次手动导入配置。 +## 3. 发送区 Ctrl+滚轮调字号 +- `SendEdit` 支持 Ctrl+滚轮调整字号(8~40pt),字号随配置持久化。 -## 4. TCP 服务端发送改为广播所有客户端 -- `NetPanel.actual_send` 服务端分支不再限定于下拉框选中的客户端,改为发送给**所有已连接客户端**。 +## 4. 关键字过滤 +- 接收设置组新增"过滤"勾选 + 关键字输入框:启用后只显示包含关键字的行。 +- 过滤状态与关键字随配置保存/恢复。 -## 5. 窗口置顶功能 -- 新增顶栏"窗口置顶"勾选框。 -- 使用 Windows `SetWindowPos` API(Win32)调整 z 序实现置顶,无闪烁; - 为 API 声明了 `argtypes` 防止 64 位句柄被截断,API 失败时回退到 `setWindowFlag`。 -- 置顶状态随 `app_config.json` 自动保存/恢复。 \ No newline at end of file +## 5. 发送历史持久化 +- 发送历史(最近 200 条)随 `app_config.json` 保存,重启后可用 ↑↓ 键继续浏览。 + +## 6. 多套配置预设(Profile) +- 顶栏"预设"下拉 + "保存预设/删除预设"按钮,可一键切换整套配置。 +- 预设保存于 `network_debugger_profiles.json`。 + +## 7. 深色主题切换 +- 顶栏"深色模式"勾选框,基于 Fusion 风格切换亮/暗色板,状态持久化。 + +## 8. DPI 适配 +- 启动时设置高分屏缩放舍入策略 `PassThrough`,避免高分屏模糊。 + +## 9. 串口 RTS/DTR 控制 + 发送文件 +- 串口面板新增 RTS/DTR 勾选,打开串口时生效,随配置保存。 +- 新增"发送文件"按钮:读取文件按 4KB 分块发送,避免阻塞界面。 + +## 10. 接收区性能优化 +- 接收区改为**批量插入 + 定时(120ms)刷新**,高频率收发不再逐行刷新导致卡顿。 + +## 11. 窗口置顶修复:弹窗继承置顶 +- 新增 `inherit_topmost()`:主窗口置顶时,批量发送/应答规则/自动应答弹窗自动带上置顶标志,避免被主窗口遮住。 +- 修复 `BatchSendDialog` 未传 `parent` 导致无法继承置顶的问题。 + +## 12. 最小化到托盘 +- 顶栏"最小化到托盘"勾选:勾选后显示托盘图标,**最小化或关闭**均隐藏到托盘,托盘菜单可"显示主界面/退出"。 +- 修复托盘图标未调用 `show()` 导致功能无效的问题。 + +## 13. 配置保存修复与自动保存 +- 修复残留隐藏 `app_config.json` 被锁定导致保存 `PermissionError`(静默失败)的问题。 +- 新增每 30 秒自动保存兜底,异常退出也不易丢配置。 +- 删除单实例锁,**默认支持多开**;`app_config.json` 加入 `.gitignore`。 \ No newline at end of file diff --git a/network_debugger.py b/network_debugger.py index 1c586b1..b9ef7ae 100644 --- a/network_debugger.py +++ b/network_debugger.py @@ -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))