"""Table editor for antenna switch positions and coordinates in metres.""" from __future__ import annotations from collections.abc import Callable from typing import cast from PyQt6.QtCore import QItemSelectionModel, QLocale, QSignalBlocker, pyqtSignal from PyQt6.QtGui import QDoubleValidator, QIntValidator from PyQt6.QtWidgets import ( QAbstractItemView, QHeaderView, QHBoxLayout, QLineEdit, QMessageBox, QPushButton, QStyledItemDelegate, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from python_app.models.run_config_validation import normalize_antenna_geometry class _CoordinateDelegate(QStyledItemDelegate): def createEditor(self, parent, option, index): editor = QLineEdit(parent) validator = ( QIntValidator(0, cast(QTableWidget, self.parent()).property("position_count") - 1, editor) if index.column() == 0 else QDoubleValidator(editor) ) validator.setLocale(QLocale.c()) editor.setValidator(validator) return editor class AntennaGeometryEditor(QWidget): """Keep geometry explicit and preserve all three coordinates on profile round trips.""" changed = pyqtSignal() def __init__(self, position_field: str, entries, *, position_count: int, validate_change: Callable[[], object] | None = None, parent=None) -> None: super().__init__(parent) self.position_field = position_field self.validate_change = validate_change self._pending_row: int | None = None self._table = QTableWidget(0, 4, self) self.set_position_count(position_count) self._table.setHorizontalHeaderLabels([ "Output" if position_field == "output_pos" else "Input", "X (m)", "Y (m)", "Z (m)", ]) cast(QHeaderView, self._table.horizontalHeader()).setSectionResizeMode(QHeaderView.ResizeMode.Stretch) cast(QHeaderView, self._table.verticalHeader()).hide() self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self._table.setItemDelegate(_CoordinateDelegate(self._table)) self._table.setMinimumHeight(130) self._table.setMaximumHeight(220) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self._table) buttons = QHBoxLayout() self._add_button = QPushButton("Add Antenna", self) self._remove_button = QPushButton("Remove Selected", self) buttons.addWidget(self._add_button) buttons.addWidget(self._remove_button) layout.addLayout(buttons) self.set_records(entries) self._table.itemChanged.connect(self._on_item_changed) self._add_button.clicked.connect(self._add_row) self._remove_button.clicked.connect(self._remove_rows) def records(self) -> list[dict]: records = [] for row in range(self._table.rowCount()): if row == self._pending_row and not cast(QTableWidgetItem, self._table.item(row, 1)).text().strip(): continue record = {} for column, key in enumerate((self.position_field, "x_m", "y_m", "z_m")): text = cast(QTableWidgetItem, self._table.item(row, column)).text() try: record[key] = int(text) if column == 0 else float(text) except ValueError as exc: raise ValueError(f"Antenna row {row + 1}: {key} must be {'an integer' if column == 0 else 'a number'}") from exc records.append(record) return normalize_antenna_geometry(records, self.position_field, self.position_count) def set_position_count(self, count: int) -> None: self.position_count = int(count) self._table.setProperty("position_count", int(count)) def set_records(self, entries) -> None: records = normalize_antenna_geometry(entries, self.position_field, self.position_count) with QSignalBlocker(self._table): self._table.setRowCount(len(records)) for row, record in enumerate(records): for column, value in enumerate(record.values()): self._table.setItem(row, column, QTableWidgetItem(str(value))) self._pending_row = None self._valid_records = records self._add_button.setEnabled(len(records) < self._table.property("position_count")) self.changed.emit() def _on_item_changed(self, _item) -> None: try: records = self.records() if records == self._valid_records: return if self.validate_change is not None: self.validate_change() except (ValueError, RuntimeError) as exc: with QSignalBlocker(self): self.set_records(self._valid_records) QMessageBox.warning(self, "Invalid Antenna Geometry", str(exc)) return self._pending_row = None self._valid_records = records self._add_button.setEnabled(len(records) < self.position_count) self.changed.emit() def _add_row(self) -> None: row = self._table.rowCount() if row >= self.position_count or self._pending_row is not None: return positions = {record[self.position_field] for record in self.records()} position = next(index for index in range(self.position_count) if index not in positions) with QSignalBlocker(self._table): self._table.insertRow(row) for column, value in enumerate((str(position), "", "0", "0")): self._table.setItem(row, column, QTableWidgetItem(value)) self._pending_row = row self._add_button.setEnabled(False) self._table.setCurrentCell(row, 1) self._table.editItem(self._table.item(row, 1)) def _remove_rows(self) -> None: rows = {index.row() for index in cast(QItemSelectionModel, self._table.selectionModel()).selectedRows()} if not rows: return with QSignalBlocker(self._table): for row in sorted(rows, reverse=True): self._table.removeRow(row) if self._pending_row is not None: self._pending_row = (None if self._pending_row in rows else self._pending_row - sum(row < self._pending_row for row in rows)) self._on_item_changed(None) self._add_button.setEnabled(self._pending_row is None and self._table.rowCount() < self.position_count)