44febe34b8
gui/qt_workers.py — one QueueWorker base replaces the per-device command queue + dispatch + signal boilerplate. The loop blocks on the queue instead of waking 10-20x/second forever (test_idle_worker_does_not_spin asserts an idle worker burns ~no CPU). PollingQueueWorker adds self-rescheduling polling: the next poll is queued only after the previous finishes, so a device slower than the interval can't accumulate a backlog (test_polling_never_overlaps_or_backs_up). Helios responsiveness — the concrete bug that motivated the above: a free running 1 s QTimer queued a status poll that took ~2 s, so the queue grew for as long as the panel stayed connected. - helios_laser._query reads until the CR terminator instead of sleeping a fixed 0.05 + 0.2 s per query - one _query_int() helper replaces five copies of parse-with-logging - polling is now driven by the worker; HeliosWindow's QTimer is gone - dropped __del__, which disabled the laser and wrote to the serial port from the garbage collector at an unpredictable time helios_test_app.py — the worker was moveToThread'd but every call site invoked its methods directly, so all serial I/O (including the sleeps) ran on the GUI thread; Query All froze the UI for ~2 s. Calls now go through a queued signal to a pyqtSlot. Also: connect/disconnect cycles leaked a QThread + worker + 9 connections each time; 16 copies of the not-connected guard collapse to _require_connection(); the Query Power button called a method that has never existed (AttributeError popup) and is now disabled and documented in KNOWN_ISSUES. DCBiasImageWidget preallocates its image and uses set_data/set_clim, so the live preview stops rebuilding the array and the whole artist tree per row (O(rows^2) over a scan). bbd20x: connect() now raises when no bays respond instead of reporting success on the wrong port; disconnect() joins with a timeout so a wedged reader can't hang shutdown; one _channel_for() helper replaces four copy-pasted axis mappings; hardcoded travel limits become TRAVEL_MM; the joke error strings are gone. gui/widgets.py adds the shared ConnectionBar / PortSelector / bounded LogConsole / StatusGrid for the test benches to adopt. 65 tests passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
6.4 KiB
Python
188 lines
6.4 KiB
Python
"""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 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)
|
|
font = QFont("Menlo")
|
|
font.setStyleHint(QFont.StyleHint.Monospace)
|
|
font.setPointSize(11)
|
|
self.view.setFont(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 "")
|