diff --git a/MODIFICATIONS.md b/MODIFICATIONS.md new file mode 100644 index 0000000..4098606 --- /dev/null +++ b/MODIFICATIONS.md @@ -0,0 +1,31 @@ +# 修改事项 + +以下为本次相对于 e9b3100(初始提交)的改动记录。 + +## 1. 接收区字体大小:Ctrl + 鼠标滚轮调整 +- 新增 `RecvEdit` 类(继承 `QPlainTextEdit`),重写 `wheelEvent`: + 按住 **Ctrl + 滚轮** 即可放大/缩小接收区字体(范围 8~40pt)。 +- 接收区改用 `RecvEdit` 并通过 `QFont` 设置字体,替换原来的 stylesheet 方式。 + +## 2. 导出/导入配置扩展 +- `export_config` 现在经 `collect_config` 导出以下内容: + - 接收设置:显示格式(ASCII/HEX)、时间戳、INFO 勾选; + - 发送设置:内容格式(文本/HEX)、回车发送、回车换行、定时发送及间隔; + - 应答设置:自动应答、解析格式、回应格式勾选; + - 字体大小、应答规则、批量发送。 +- **保存日志(chk_save_log)不参与导出/导入**,避免日志目录误恢复。 +- `import_config` 通过 `apply_config` 统一恢复上述设置,兼容旧的 `reply_rules` / `batch_rows` 配置文件格式。 + +## 3. 关闭自动保存、重启自动恢复配置 +- `MainWindow` 持有串口/TCP/UDP 三个面板引用。 +- `closeEvent` 时把各面板配置写入同目录 `app_config.json`。 +- 启动时自动读取并恢复,无需每次手动导入配置。 + +## 4. TCP 服务端发送改为广播所有客户端 +- `NetPanel.actual_send` 服务端分支不再限定于下拉框选中的客户端,改为发送给**所有已连接客户端**。 + +## 5. 窗口置顶功能 +- 新增顶栏"窗口置顶"勾选框。 +- 使用 Windows `SetWindowPos` API(Win32)调整 z 序实现置顶,无闪烁; + 为 API 声明了 `argtypes` 防止 64 位句柄被截断,API 失败时回退到 `setWindowFlag`。 +- 置顶状态随 `app_config.json` 自动保存/恢复。 \ No newline at end of file diff --git a/network_debugger.py b/network_debugger.py index 72cd220..1c586b1 100644 --- a/network_debugger.py +++ b/network_debugger.py @@ -26,6 +26,7 @@ from PySide6.QtWidgets import ( QPlainTextEdit, QCheckBox, QSpinBox, QSplitter, QMessageBox, QTableWidget, QTableWidgetItem, QListWidget, QListWidgetItem, QGroupBox, QRadioButton, QHeaderView, QFileDialog, QWidgetItem, QLayout, + QToolBar, ) STAMP_OPTS = ['无', 'ASCII(YYYY-MM-DD HH:MM:SS)', 'ASCII(HH:MM:SS)', @@ -132,6 +133,36 @@ class SendEdit(QPlainTextEdit): super().keyPressEvent(e) +class RecvEdit(QPlainTextEdit): + """接收区编辑框:Ctrl+滚轮 调整字体大小""" + + MIN_FONT = 8 + MAX_FONT = 40 + DEFAULT_FONT = 11 + + 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() + font.setPointSize(self._font_size) + self.setFont(font) + + def font_size(self): + return self._font_size + + class NoWheelSpinBox(QSpinBox): def wheelEvent(self, event): event.ignore() @@ -191,6 +222,17 @@ def match_rule(hex_text: str, data: bytes) -> bool: return find_match(hex_text, data) != -1 APP_NAME = "网络调试助手" + + +def app_config_path(): + """返回自动保存配置的文件路径(与主程序同目录)""" + if getattr(sys, 'frozen', False): + base = os.path.dirname(sys.executable) + else: + base = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(base, 'app_config.json') + + BAUDS = ['1200', '2400', '4800', '9600', '19200', '38400', '57600', '115200'] PARITIES = {'无校验': serial.PARITY_NONE, '奇校验': serial.PARITY_ODD, '偶校验': serial.PARITY_EVEN} @@ -975,10 +1017,10 @@ class BasePanel(QWidget): rv = QVBoxLayout(recv_widget) rv.setContentsMargins(0, 0, 0, 0) rv.addWidget(QLabel('接收区')) - self.recv = QPlainTextEdit() + self.recv = RecvEdit() self.recv.setReadOnly(True) self.recv.setMaximumBlockCount(2000) - self.recv.setStyleSheet('QPlainTextEdit{font-family:Consolas;font-size:11pt}') + self.recv.setFont(QFont('Consolas', RecvEdit.DEFAULT_FONT)) rv.addWidget(self.recv, 1) splitter.addWidget(recv_widget) @@ -1341,9 +1383,91 @@ class BasePanel(QWidget): if dlg.exec() == QDialog.Accepted: self.reply_rules = dlg.result() + def collect_config(self): + return { + 'recv': { + 'display': 'HEX' if self.rb_recv_hex.isChecked() else 'ASCII', + 'ts': self.chk_ts.isChecked(), + 'info': self.chk_info.isChecked(), + }, + 'send': { + 'format': 'HEX' if self.rb_send_hex.isChecked() else '文本', + 'enter_send': self.chk_enter_send.isChecked(), + 'crlf': self.chk_crlf.isChecked(), + 'auto': self.chk_auto.isChecked(), + 'interval': self.spin_interval.value(), + }, + 'reply': { + 'enabled': self.chk_reply.isChecked(), + 'parse': 'HEX' if self.rb_parse_hex.isChecked() else 'ASCII', + 'respond': 'HEX' if self.rb_reply_hex.isChecked() else 'ASCII', + }, + 'font_size': self.recv.font_size(), + 'reply_rules': copy.deepcopy(self.reply_rules), + 'batch_rows': copy.deepcopy(self.batch_rows), + } + + def apply_config(self, cfg): + if not isinstance(cfg, dict): + return [] + msg = [] + recv = cfg.get('recv') + if isinstance(recv, dict): + display = recv.get('display', 'HEX') + self.rb_recv_hex.setChecked(display == 'HEX') + self.rb_recv_ascii.setChecked(display != 'HEX') + if 'ts' in recv: + self.chk_ts.setChecked(bool(recv['ts'])) + if 'info' in recv: + self.chk_info.setChecked(bool(recv['info'])) + msg.append('接收设置') + send = cfg.get('send') + if isinstance(send, dict): + fmt = send.get('format', '文本') + self.rb_send_hex.setChecked(fmt == 'HEX') + self.rb_send_text.setChecked(fmt != 'HEX') + if 'enter_send' in send: + self.chk_enter_send.setChecked(bool(send['enter_send'])) + if 'crlf' in send: + self.chk_crlf.setChecked(bool(send['crlf'])) + if 'auto' in send: + self.chk_auto.setChecked(bool(send['auto'])) + if 'interval' in send: + self.spin_interval.setValue(int(send['interval'])) + msg.append('发送设置') + reply = cfg.get('reply') + if isinstance(reply, dict): + if 'enabled' in reply: + self.chk_reply.setChecked(bool(reply['enabled'])) + if 'parse' in reply: + self.rb_parse_hex.setChecked(reply['parse'] == 'HEX') + self.rb_parse_ascii.setChecked(reply['parse'] != 'HEX') + if 'respond' in reply: + self.rb_reply_hex.setChecked(reply['respond'] == 'HEX') + self.rb_reply_ascii.setChecked(reply['respond'] != 'HEX') + msg.append('应答设置') + if 'font_size' in cfg: + try: + self.recv.set_font_size(int(cfg['font_size'])) + except (TypeError, ValueError): + pass + msg.append('字体大小') + rr = cfg.get('reply_rules') + if isinstance(rr, list) and all(isinstance(x, dict) for x in rr): + for r in rr: + r.setdefault('enabled', True) + self.reply_rules = rr + msg.append(f'应答配置 {len(rr)} 条') + br = cfg.get('batch_rows') + if isinstance(br, list) and all(isinstance(x, dict) for x in br): + self.batch_rows = [{'enabled': bool(x.get('enabled', True)), + 'delay': int(x.get('delay', 0)), + 'text': str(x.get('text', ''))} for x in br] + msg.append(f'批量发送 {len(br)} 条') + return msg + def export_config(self): - data = {'reply_rules': copy.deepcopy(self.reply_rules), - 'batch_rows': copy.deepcopy(self.batch_rows)} + data = self.collect_config() default = f'config_{datetime.now():%Y%m%d_%H%M%S}.json' path, _ = QFileDialog.getSaveFileName(self, '导出配置', default, 'JSON 文件 (*.json);;所有文件 (*)') @@ -1370,22 +1494,7 @@ class BasePanel(QWidget): if not isinstance(data, dict): QMessageBox.warning(self, '导入失败', '配置文件格式无效') return - msg = [] - rr = data.get('reply_rules') - if isinstance(rr, list) and rr and all(isinstance(x, dict) for x in rr): - for r in rr: - r.setdefault('enabled', True) - self.reply_rules = rr - msg.append(f'应答配置 {len(rr)} 条') - br = data.get('batch_rows') - if isinstance(br, list): - if not all(isinstance(x, dict) for x in br): - QMessageBox.warning(self, '导入失败', '批量发送配置格式无效') - return - self.batch_rows = [{'enabled': bool(x.get('enabled', True)), - 'delay': int(x.get('delay', 0)), - 'text': str(x.get('text', ''))} for x in br] - msg.append(f'批量发送 {len(br)} 条') + msg = self.apply_config(data) if not msg: QMessageBox.warning(self, '导入失败', '配置文件中没有可导入的数据') return @@ -1847,12 +1956,8 @@ class NetPanel(BasePanel): QMessageBox.warning(self, '错误', f'发送失败\n{e}') return False # TCP 服务器 - sel = self.combox_client.currentText() with self.lock: - if sel and sel in self.clients: - targets = [self.clients[sel]] - else: - targets = list(self.clients.values()) + targets = list(self.clients.values()) if not targets: QMessageBox.warning(self, '提示', '没有已连接的客户端') return False @@ -1885,10 +1990,85 @@ class MainWindow(QMainWindow): if icon: self.setWindowIcon(QIcon(icon)) tabs = QTabWidget() - tabs.addTab(SerialPanel(), '串口') - tabs.addTab(NetPanel('tcp'), 'TCP') - tabs.addTab(NetPanel('udp'), 'UDP') + self.serial_panel = SerialPanel() + self.tcp_panel = NetPanel('tcp') + self.udp_panel = NetPanel('udp') + tabs.addTab(self.serial_panel, '串口') + tabs.addTab(self.tcp_panel, 'TCP') + tabs.addTab(self.udp_panel, 'UDP') self.setCentralWidget(tabs) + self.top_btn = QCheckBox('窗口置顶') + self.top_btn.setToolTip('选中后窗口保持在最前端') + self.top_btn.toggled.connect(self._toggle_topmost) + tb = QToolBar('选项') + tb.setMovable(False) + tb.addWidget(self.top_btn) + self.addToolBar(tb) + self._load_app_config() + + def _toggle_topmost(self, on: bool): + try: + import ctypes + user32 = ctypes.windll.user32 + user32.SetWindowPos.argtypes = [ + ctypes.c_void_p, ctypes.c_void_p, + ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, + ctypes.c_uint, + ] + user32.SetWindowPos.restype = ctypes.c_int + HWND_TOPMOST = -1 + HWND_NOTOPMOST = -2 + SWP_NOMOVE = 0x0002 + SWP_NOSIZE = 0x0001 + SWP_NOACTIVATE = 0x0010 + ok = user32.SetWindowPos( + ctypes.c_void_p(int(self.winId())), + ctypes.c_void_p(HWND_TOPMOST if on else HWND_NOTOPMOST), + 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE) != 0 + if ok: + return + except Exception: + pass + self._fallback_topmost(on) + + def _fallback_topmost(self, on: bool): + 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 _load_app_config(self): + try: + with open(app_config_path(), 'r', encoding='utf-8') as f: + data = json.load(f) + except Exception: + return + 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'), + (self.tcp_panel, 'tcp'), + (self.udp_panel, 'udp')): + cfg = tabs_cfg.get(key) + if isinstance(cfg, dict): + panel.apply_config(cfg) + + def closeEvent(self, event): + self._save_app_config() + super().closeEvent(event) if __name__ == '__main__':