feat(iomodbus): spec 5.2 toolbar - suite button, connection chip, catalog menu, disconnect
- Add suiteRequested Signal; act_suite triggers it (SuiteWindow wires this to
show_launcher via the existing open_module hook - no shell change needed)
- Add ConnectionChip (chip) to toolbar; set_connection_source(source) feeds it
from module._start_simulator ("SIM") and _connect_to_port (port string)
- _on_connection updates chip and toggles act_connect text Connect/Disconnect
- _toggle_connection dispatches to _ctrl.stop() (disconnect) or _connect() (connect)
- Delete _build_menu / menuBar usage; fold Import Devices, Export Devices,
Supported Devices into Catalog toolbar QToolButton menu with tooltips visible
- Also fold Export This Tab / Export All Tabs into Catalog menu (BL-DS5 trim):
with IBM Plex Mono loaded the two export toolbar buttons push sizeHint above
1240px; grouping all file-level operations under Catalog is semantically
coherent and keeps the toolbar at ~1093px (themed offscreen) with real margin
- "Log Measurements" -> "Log" (tooltip preserved) - further trim per task spec
- Drop " Address: " and "Update: " bare QLabels; addr_edit gets
setPlaceholderText("Address"), update_combo gets a tooltip
- resize 1100x720 -> 1280x760 (suite default)
- module.py: set_connection_source("SIM"/"port") before attach in both
_start_simulator and _connect_to_port
Tests: 5 new in tests/iomodbus/test_main_window_toolbar.py; suite 1010 passed.
Toolbar sizeHint: 1093px (themed+offscreen) vs 1240px limit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,8 @@ class IomodbusModule:
|
||||
def _start_simulator(self) -> None:
|
||||
if self._controller is None:
|
||||
return
|
||||
if self._window is not None:
|
||||
self._window.set_connection_source("SIM")
|
||||
from .transport.simulator import SimulatedModbusBus
|
||||
|
||||
self._sim = SimulatedModbusBus(self._catalog)
|
||||
@@ -64,6 +66,8 @@ class IomodbusModule:
|
||||
|
||||
if self._controller is None:
|
||||
return
|
||||
if self._window is not None:
|
||||
self._window.set_connection_source(port)
|
||||
if self._controller._transport is not None:
|
||||
self._controller.stop()
|
||||
if baud is None:
|
||||
|
||||
@@ -11,7 +11,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QAction
|
||||
from PySide6.QtWidgets import (
|
||||
QDialogButtonBox,
|
||||
@@ -22,18 +22,24 @@ from PySide6.QtWidgets import (
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from cim_suite.core.ui.chrome import ChromeDialog, StatusFooter
|
||||
from cim_suite.core.ui.copy_menu import enable_copy_in
|
||||
from cim_suite.core.ui.export_action import add_export_actions
|
||||
from cim_suite.core.ui.kit import InstrumentTabWidget
|
||||
from cim_suite.core.ui.export_action import (
|
||||
collect_all,
|
||||
collect_sheet,
|
||||
save_sheets_dialog,
|
||||
)
|
||||
from cim_suite.core.ui.kit import ConnectionChip, InstrumentTabWidget
|
||||
|
||||
from .. import config
|
||||
from .. import help as H
|
||||
@@ -66,13 +72,17 @@ def parse_address_range(text: str) -> tuple[int, int]:
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
# Emitted when the user clicks ← Suite; SuiteWindow connects to show_launcher.
|
||||
suiteRequested = Signal()
|
||||
|
||||
def __init__(self, controller, connector: Callable[[str, int], None] | None = None) -> None:
|
||||
super().__init__()
|
||||
self._ctrl = controller
|
||||
self._connector = connector
|
||||
self._connected = False
|
||||
self._source = ""
|
||||
self.setWindowTitle("IOModbus Service Utility")
|
||||
self.resize(1100, 720)
|
||||
self.resize(1280, 760)
|
||||
|
||||
self.settings_tab = SettingsTab(controller)
|
||||
self.channels_tab = ChannelsTab(controller)
|
||||
@@ -107,7 +117,6 @@ class MainWindow(QMainWindow):
|
||||
self.setCentralWidget(outer)
|
||||
|
||||
self._build_toolbar()
|
||||
self._build_menu()
|
||||
self._build_statusbar()
|
||||
|
||||
controller.devicesChanged.connect(self._rebuild_devices)
|
||||
@@ -121,20 +130,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
enable_copy_in(self)
|
||||
|
||||
# --- catalog menu ------------------------------------------------------
|
||||
def _build_menu(self) -> None:
|
||||
menu = self.menuBar().addMenu("&Catalog")
|
||||
act_import = QAction("Import Devices…", self)
|
||||
act_import.triggered.connect(self._import_devices)
|
||||
act_export = QAction("Export Devices…", self)
|
||||
act_export.triggered.connect(self._export_devices)
|
||||
act_supported = QAction("Supported Devices…", self)
|
||||
act_supported.triggered.connect(self._show_supported_devices)
|
||||
menu.addAction(act_import)
|
||||
menu.addAction(act_export)
|
||||
menu.addSeparator()
|
||||
menu.addAction(act_supported)
|
||||
|
||||
# --- catalog / import helpers ------------------------------------------
|
||||
def _reload_catalog(self) -> None:
|
||||
self._ctrl.set_catalog(config.load_catalog())
|
||||
self._on_alert("Catalog updated — re-scan to discover newly added devices.")
|
||||
@@ -263,56 +259,95 @@ class MainWindow(QMainWindow):
|
||||
def _build_toolbar(self) -> None:
|
||||
tb = self.addToolBar("Main")
|
||||
tb.setMovable(False)
|
||||
self._toolbar = tb # kept for the BL-DS5 width regression test
|
||||
|
||||
# §5.2: [← Suite] | IOModbus Service Utility [chip] …… [actions] | [Connect]
|
||||
self.act_suite = QAction("← Suite", self)
|
||||
self.act_suite.triggered.connect(self.suiteRequested.emit)
|
||||
tb.addAction(self.act_suite)
|
||||
tb.addSeparator()
|
||||
|
||||
brand = QLabel("IOModbus")
|
||||
brand.setObjectName("BrandTitle")
|
||||
tb.addWidget(brand)
|
||||
tb.addSeparator()
|
||||
|
||||
act_connect = QAction("Connect…", self)
|
||||
act_connect.triggered.connect(self._connect)
|
||||
tb.addAction(act_connect)
|
||||
btn = tb.widgetForAction(act_connect)
|
||||
if btn is not None:
|
||||
btn.setObjectName("PrimaryAction")
|
||||
|
||||
tb.addWidget(QLabel(" Address: "))
|
||||
self.addr_edit = QLineEdit("1")
|
||||
self.addr_edit.setFixedWidth(80)
|
||||
self.addr_edit.setToolTip(H.COMM["address"])
|
||||
self.addr_edit.returnPressed.connect(self._scan)
|
||||
tb.addWidget(self.addr_edit)
|
||||
|
||||
act_scan = QAction("Scan", self)
|
||||
act_scan.triggered.connect(self._scan)
|
||||
tb.addAction(act_scan)
|
||||
act_stop = QAction("Stop", self)
|
||||
act_stop.triggered.connect(self._ctrl.stop_polling)
|
||||
tb.addAction(act_stop)
|
||||
act_refresh = QAction("Refresh", self)
|
||||
act_refresh.triggered.connect(self._ctrl.refresh)
|
||||
tb.addAction(act_refresh)
|
||||
descriptor = QLabel("Service Utility")
|
||||
descriptor.setObjectName("BrandTitleAccent")
|
||||
tb.addWidget(descriptor)
|
||||
self.chip = ConnectionChip()
|
||||
tb.addWidget(self.chip)
|
||||
|
||||
# Push everything left; remaining actions float right.
|
||||
spacer = QWidget()
|
||||
spacer.setObjectName("ToolbarSpacer")
|
||||
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
tb.addWidget(spacer)
|
||||
|
||||
tb.addWidget(QLabel("Update: "))
|
||||
self.update_combo = _update_combo(self._ctrl.set_update_interval)
|
||||
tb.addWidget(self.update_combo)
|
||||
# Scan group: address range + scan/stop (the bus-discovery verbs).
|
||||
self.addr_edit = QLineEdit("1")
|
||||
self.addr_edit.setFixedWidth(80)
|
||||
self.addr_edit.setPlaceholderText("Address")
|
||||
self.addr_edit.setToolTip(H.COMM["address"])
|
||||
self.addr_edit.returnPressed.connect(self._scan)
|
||||
tb.addWidget(self.addr_edit)
|
||||
act_scan = QAction("Scan", self)
|
||||
act_scan.setToolTip(H.BUTTONS["Scan"])
|
||||
act_scan.triggered.connect(self._scan)
|
||||
tb.addAction(act_scan)
|
||||
act_stop = QAction("Stop", self)
|
||||
act_stop.setToolTip(H.BUTTONS["Stop"])
|
||||
act_stop.triggered.connect(self._ctrl.stop_polling)
|
||||
tb.addAction(act_stop)
|
||||
|
||||
self.act_log = QAction("Log Measurements", self)
|
||||
tb.addSeparator()
|
||||
act_refresh = QAction("Refresh", self)
|
||||
act_refresh.setToolTip(H.BUTTONS["Refresh"])
|
||||
act_refresh.triggered.connect(self._ctrl.refresh)
|
||||
tb.addAction(act_refresh)
|
||||
self.update_combo = _update_combo(self._ctrl.set_update_interval)
|
||||
self.update_combo.setToolTip("How often the selected device's registers are polled.")
|
||||
tb.addWidget(self.update_combo)
|
||||
self.act_log = QAction("Log", self)
|
||||
self.act_log.setToolTip("Log live register values to a CSV file.")
|
||||
self.act_log.setCheckable(True)
|
||||
self.act_log.toggled.connect(self._ctrl.set_logging)
|
||||
tb.addAction(self.act_log)
|
||||
|
||||
tb.addSeparator()
|
||||
add_export_actions(
|
||||
tb, self.tabs, self, self._export_metadata,
|
||||
file_prefix="IOModbus",
|
||||
last_dir=config.export_dir,
|
||||
remember_dir=config.set_export_dir,
|
||||
)
|
||||
# The legacy &Catalog menubar folds into the toolbar (§5.2: single row).
|
||||
# Data-export actions also live here (file-level operations; keeps the toolbar narrow).
|
||||
menu_btn = QToolButton(tb)
|
||||
menu_btn.setText("Catalog ▾")
|
||||
self.catalog_menu = QMenu(menu_btn)
|
||||
self.catalog_menu.setToolTipsVisible(True) # QMenu hides action tooltips by default
|
||||
self.act_import = self.catalog_menu.addAction("Import Devices…")
|
||||
self.act_import.setToolTip("Merge devices from an IOModbus.txt catalog file.")
|
||||
self.act_import.triggered.connect(self._import_devices)
|
||||
self.act_export_catalog = self.catalog_menu.addAction("Export Devices…")
|
||||
self.act_export_catalog.setToolTip("Write the effective catalog to an IOModbus.txt file.")
|
||||
self.act_export_catalog.triggered.connect(self._export_devices)
|
||||
self.catalog_menu.addSeparator()
|
||||
self.act_supported = self.catalog_menu.addAction("Supported Devices…")
|
||||
self.act_supported.triggered.connect(self._show_supported_devices)
|
||||
self.catalog_menu.addSeparator()
|
||||
# Export data — placed here rather than as separate toolbar buttons (BL-DS5: toolbar fit).
|
||||
self.act_export_tab = self.catalog_menu.addAction("Export This Tab…")
|
||||
self.act_export_tab.setToolTip("Export the active data tab to an Excel workbook.")
|
||||
self.act_export_tab.triggered.connect(self._export_this_tab)
|
||||
self.act_export_all = self.catalog_menu.addAction("Export All Tabs…")
|
||||
self.act_export_all.setToolTip("Export all data tabs to a single Excel workbook.")
|
||||
self.act_export_all.triggered.connect(self._export_all_tabs)
|
||||
menu_btn.setMenu(self.catalog_menu)
|
||||
menu_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||
tb.addWidget(menu_btn)
|
||||
|
||||
tb.addSeparator()
|
||||
self.act_connect = QAction("Connect…", self)
|
||||
self.act_connect.triggered.connect(self._toggle_connection)
|
||||
tb.addAction(self.act_connect)
|
||||
# Highlight Connect/Disconnect as the primary call-to-action.
|
||||
btn = tb.widgetForAction(self.act_connect)
|
||||
if btn is not None:
|
||||
btn.setObjectName("PrimaryAction")
|
||||
|
||||
def _build_statusbar(self) -> None:
|
||||
self.footer = StatusFooter()
|
||||
@@ -322,6 +357,17 @@ class MainWindow(QMainWindow):
|
||||
tooltip="Address being probed by the device scan")
|
||||
|
||||
# --- actions -----------------------------------------------------------
|
||||
def set_connection_source(self, source: str) -> None:
|
||||
"""The port label shown in the chip (COM5, SIM). Called by the module."""
|
||||
self._source = source
|
||||
self.chip.set_state(self._connected, source)
|
||||
|
||||
def _toggle_connection(self) -> None:
|
||||
if self._connected:
|
||||
self._ctrl.stop()
|
||||
else:
|
||||
self._connect()
|
||||
|
||||
def _connect(self) -> None:
|
||||
cfg = config.load_config()
|
||||
dlg = ComSetupDialog(cfg.get("port", ""), cfg.get("baud", 9600), self)
|
||||
@@ -329,9 +375,28 @@ class MainWindow(QMainWindow):
|
||||
cfg["port"] = dlg.selected_port()
|
||||
cfg["baud"] = dlg.selected_baud()
|
||||
config.save_config(cfg)
|
||||
self.set_connection_source(dlg.selected_port())
|
||||
if self._connector is not None:
|
||||
self._connector(dlg.selected_port(), dlg.selected_baud())
|
||||
|
||||
def _export_this_tab(self) -> None:
|
||||
meta = self._export_metadata()
|
||||
idx = self.tabs.currentIndex()
|
||||
sheet = collect_sheet(self.tabs, idx, meta)
|
||||
label = self.tabs.tabText(idx)
|
||||
if sheet:
|
||||
save_sheets_dialog(
|
||||
self, f"IOModbus_{label}.xlsx", [sheet],
|
||||
last_dir=config.export_dir, remember_dir=config.set_export_dir,
|
||||
)
|
||||
|
||||
def _export_all_tabs(self) -> None:
|
||||
sheets = collect_all(self.tabs, self._export_metadata())
|
||||
save_sheets_dialog(
|
||||
self, "IOModbus_AllTabs.xlsx", sheets,
|
||||
last_dir=config.export_dir, remember_dir=config.set_export_dir,
|
||||
)
|
||||
|
||||
def _scan(self) -> None:
|
||||
start, end = parse_address_range(self.addr_edit.text())
|
||||
self._ctrl.scan(start, end)
|
||||
@@ -353,6 +418,8 @@ class MainWindow(QMainWindow):
|
||||
def _on_connection(self, connected: bool) -> None:
|
||||
self._connected = connected
|
||||
self.footer.set_dot("link", "ok" if connected else "off")
|
||||
self.chip.set_state(connected, self._source)
|
||||
self.act_connect.setText("Disconnect" if connected else "Connect…")
|
||||
|
||||
def _on_scan_progress(self, addr: int) -> None:
|
||||
self.footer.set_field("scan", f"ADDR {addr}")
|
||||
|
||||
50
tests/iomodbus/test_main_window_toolbar.py
Normal file
50
tests/iomodbus/test_main_window_toolbar.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Spec §5.2 toolbar: suite handoff, connection chip, Catalog menu, disconnect."""
|
||||
|
||||
import pytest
|
||||
|
||||
from cim_suite.modules.iomodbus.domain.controller import ModbusController
|
||||
from cim_suite.modules.iomodbus.ui.main_window import MainWindow
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def window(qtbot, catalog):
|
||||
ctrl = ModbusController(catalog)
|
||||
w = MainWindow(ctrl)
|
||||
qtbot.addWidget(w)
|
||||
return w
|
||||
|
||||
|
||||
def test_suite_button_emits_signal(window, qtbot):
|
||||
with qtbot.waitSignal(window.suiteRequested, timeout=1000):
|
||||
window.act_suite.trigger()
|
||||
|
||||
|
||||
def test_chip_tracks_connection(window):
|
||||
window.set_connection_source("COM5")
|
||||
window._ctrl.connectionChanged.emit(True)
|
||||
assert window.chip.text() == "Connected · COM5"
|
||||
assert window.act_connect.text().startswith("Disconnect")
|
||||
window._ctrl.connectionChanged.emit(False)
|
||||
assert window.chip.text() == "Not connected"
|
||||
assert window.act_connect.text().startswith("Connect")
|
||||
|
||||
|
||||
def test_catalog_menu_replaces_the_menubar(window):
|
||||
labels = [a.text() for a in window.catalog_menu.actions() if a.text()]
|
||||
assert any("Import Devices" in t for t in labels)
|
||||
assert any("Export Devices" in t for t in labels)
|
||||
assert any("Supported Devices" in t for t in labels)
|
||||
assert window.menuBar().actions() == [] # the old menubar is gone
|
||||
|
||||
|
||||
def test_disconnect_stops_controller(window, monkeypatch):
|
||||
stopped = []
|
||||
monkeypatch.setattr(window._ctrl, "stop", lambda: stopped.append(True))
|
||||
window._ctrl.connectionChanged.emit(True)
|
||||
window.act_connect.trigger()
|
||||
assert stopped == [True]
|
||||
|
||||
|
||||
def test_toolbar_fits_the_default_window(window):
|
||||
# BL-DS5 evidence: the widest suite toolbar fits the 1280px default with margin.
|
||||
assert window._toolbar.sizeHint().width() <= 1240
|
||||
Reference in New Issue
Block a user