"""Widgets shared by the main app and the per-device test benches. Each of these was hand-rolled several times across the apps, with slightly different behaviour every time (only one log console bounded its buffer, only one port picker sorted by device type). """ from __future__ import annotations from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QFont from PyQt6.QtWidgets import ( QComboBox, QGroupBox, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget, ) from hardware.serial_util import scored_ports def mono_font(size: int = 11) -> QFont: """Monospace font for numeric read-outs, so columns of digits line up.""" font = QFont("Menlo") font.setStyleHint(QFont.StyleHint.Monospace) font.setPointSize(size) return font def set_toggle(btn, checked: bool, text: str, enabled: bool = True): """Update a checkable button without re-triggering its toggled signal.""" btn.blockSignals(True) btn.setChecked(checked) btn.setText(text) btn.setEnabled(enabled) btn.blockSignals(False) class PortSelector(QWidget): """Serial port combo + refresh button, likeliest device first.""" def __init__(self, parent=None): super().__init__(parent) row = QHBoxLayout(self) row.setContentsMargins(0, 0, 0, 0) self.combo = QComboBox() self.combo.setMinimumWidth(220) refresh = QPushButton("⟳") refresh.setFixedWidth(30) refresh.setToolTip("Rescan serial ports") refresh.clicked.connect(self.refresh) row.addWidget(self.combo, stretch=1) row.addWidget(refresh) self.refresh() def refresh(self): """Repopulate the list, preserving the current selection if present.""" current = self.current_port() self.combo.clear() for device, label in scored_ports(): self.combo.addItem(label, device) if self.combo.count() == 0: self.combo.addItem("(no serial ports found)", None) elif current: idx = self.combo.findData(current) if idx >= 0: self.combo.setCurrentIndex(idx) def current_port(self) -> str | None: return self.combo.currentData() def set_port(self, device: str): idx = self.combo.findData(device) if idx >= 0: self.combo.setCurrentIndex(idx) class ConnectionBar(QGroupBox): """Port picker + connect toggle + status label. Emits connect_requested(port) / disconnect_requested(); the owner drives the state back through on_connected/on_disconnected/on_failed so the button can never disagree with the hardware. """ connect_requested = pyqtSignal(str) disconnect_requested = pyqtSignal() def __init__(self, title: str = "Connection", parent=None): super().__init__(title, parent) layout = QVBoxLayout(self) self.port_selector = PortSelector() layout.addWidget(self.port_selector) row = QHBoxLayout() self.toggle = QPushButton("Connect") self.toggle.setCheckable(True) self.toggle.toggled.connect(self._on_toggled) self.status = QLabel("Disconnected") self.status.setStyleSheet("font-weight: bold;") row.addWidget(self.toggle) row.addWidget(self.status, stretch=1) layout.addLayout(row) def _on_toggled(self, checked: bool): if checked: port = self.port_selector.current_port() if not port: set_toggle(self.toggle, False, "Connect") self.status.setText("No port selected") return self.toggle.setText("Connecting…") self.toggle.setEnabled(False) self.connect_requested.emit(port) else: self.disconnect_requested.emit() def on_connected(self, detail: str = "Connected"): set_toggle(self.toggle, True, "Disconnect") self.status.setText(detail) self.status.setStyleSheet("font-weight: bold; color: green;") def on_disconnected(self, detail: str = "Disconnected"): set_toggle(self.toggle, False, "Connect") self.status.setText(detail) self.status.setStyleSheet("font-weight: bold;") def on_failed(self, message: str): set_toggle(self.toggle, False, "Connect") self.status.setText(f"Failed: {message}") self.status.setStyleSheet("font-weight: bold; color: red;") class LogConsole(QWidget): """Bounded, monospace, auto-scrolling log view with a Clear button. The block cap is the point: unbounded QTextEdit logs grew for the whole session in every app that hand-rolled one. """ KINDS = {"tx": "→", "rx": "←", "info": "●", "err": "!"} def __init__(self, max_blocks: int = 2000, parent=None): super().__init__(parent) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) self.view = QPlainTextEdit() self.view.setReadOnly(True) self.view.setMaximumBlockCount(max_blocks) self.view.setFont(mono_font()) layout.addWidget(self.view) row = QHBoxLayout() row.addStretch(1) clear = QPushButton("Clear") clear.clicked.connect(self.view.clear) row.addWidget(clear) layout.addLayout(row) def log(self, message: str, kind: str = "info"): self.view.appendPlainText(f"{self.KINDS.get(kind, '●')} {message}") self.view.verticalScrollBar().setValue( self.view.verticalScrollBar().maximum()) class StatusGrid(QWidget): """Label/value rows with consistent ok/warn/error colouring.""" _COLORS = {"ok": "green", "warn": "#b8860b", "error": "red", "": ""} def __init__(self, fields: list[str], parent=None): super().__init__(parent) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) self._values: dict[str, QLabel] = {} for name in fields: row = QHBoxLayout() label = QLabel(f"{name}:") value = QLabel("—") value.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) row.addWidget(label) row.addWidget(value, stretch=1) layout.addLayout(row) self._values[name] = value def set(self, name: str, text: str, state: str = ""): label = self._values.get(name) if label is None: return label.setText(text) color = self._COLORS.get(state, "") label.setStyleSheet(f"color: {color};" if color else "")