"""Editor de equipos del Studio (post-wizard). Tabla con todos los equipos del Project. CRUD básico: - Agregar: dialog modal con campos - Editar: doble-click en celda (inline) - Eliminar: tecla Delete o botón """ from __future__ import annotations import uuid from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMessageBox, QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from vmssailor.core.coords import ShipCoord from vmssailor.core.enums import SystemId from vmssailor.core.equipment import Equipment from vmssailor.core.project import Project from vmssailor.library import load_library from vmssailor.studio.theme import C_CYAN, C_FOG, mono_font, ui_font _COLS = ["ID", "Tag prefix", "Nombre", "Modelo", "Sistema", "x_pp", "y_cl", "z_bl", "Deck"] class EquipmentEditor(QWidget): """Editor CRUD de equipos del proyecto activo.""" projectMutated = Signal(Project) """Emitido cuando se agrega, edita o elimina un equipo.""" def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self._project: Project | None = None layout = QVBoxLayout(self) layout.setContentsMargins(16, 16, 16, 16) layout.setSpacing(12) # Header header = QLabel("Editor de equipos") header.setObjectName("title") header.setFont(ui_font(14)) layout.addWidget(header) intro = QLabel( "CRUD de equipos del proyecto. Doble-click en celda para editar inline. " "El editor de mímicos y tags llega en Sprint 3." ) intro.setStyleSheet(f"color: {C_FOG};") intro.setFont(ui_font(10)) intro.setWordWrap(True) layout.addWidget(intro) # Buttons row btn_row = QHBoxLayout() self._btn_add = QPushButton("+ Agregar equipo") self._btn_add.setObjectName("primary") self._btn_remove = QPushButton("Eliminar seleccionado") self._counter = QLabel("0 equipos") self._counter.setFont(mono_font(10)) self._counter.setStyleSheet(f"color: {C_CYAN};") btn_row.addWidget(self._btn_add) btn_row.addWidget(self._btn_remove) btn_row.addStretch(1) btn_row.addWidget(self._counter) layout.addLayout(btn_row) # Table self._table = QTableWidget(0, len(_COLS)) self._table.setHorizontalHeaderLabels(_COLS) self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) self._table.verticalHeader().setVisible(False) self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self._table.setEditTriggers( QAbstractItemView.EditTrigger.DoubleClicked | QAbstractItemView.EditTrigger.EditKeyPressed ) self._table.itemChanged.connect(self._on_item_changed) layout.addWidget(self._table, 1) # Signals self._btn_add.clicked.connect(self._on_add) self._btn_remove.clicked.connect(self._on_remove) # ----- Public API --------------------------------------------------- def set_project(self, project: Project | None) -> None: self._project = project self._refresh_table() # ----- Internals ---------------------------------------------------- def _refresh_table(self) -> None: self._table.blockSignals(True) self._table.setRowCount(0) if self._project is None: self._counter.setText("0 equipos") self._table.blockSignals(False) return for eq in self._project.equipment: self._append_row(eq) self._counter.setText(f"{len(self._project.equipment)} equipos") self._table.blockSignals(False) def _append_row(self, eq: Equipment) -> None: row = self._table.rowCount() self._table.insertRow(row) id_item = QTableWidgetItem(eq.id) id_item.setFlags(id_item.flags() & ~Qt.ItemFlag.ItemIsEditable) id_item.setForeground(Qt.GlobalColor.gray) self._table.setItem(row, 0, id_item) self._table.setItem(row, 1, QTableWidgetItem(eq.tag_prefix)) self._table.setItem(row, 2, QTableWidgetItem(eq.display_name)) self._table.setItem(row, 3, QTableWidgetItem(eq.model_ref)) sys_item = QTableWidgetItem(eq.system_id.value) sys_item.setForeground(Qt.GlobalColor.cyan) self._table.setItem(row, 4, sys_item) self._table.setItem(row, 5, QTableWidgetItem(f"{eq.location.x_pp:.2f}")) self._table.setItem(row, 6, QTableWidgetItem(f"{eq.location.y_cl:.2f}")) self._table.setItem(row, 7, QTableWidgetItem(f"{eq.location.z_bl:.2f}")) self._table.setItem(row, 8, QTableWidgetItem(eq.deck_id or "")) def _on_item_changed(self, item: QTableWidgetItem) -> None: if self._project is None: return row = item.row() if row >= len(self._project.equipment): return eq = self._project.equipment[row] col = item.column() try: if col == 1: # tag_prefix new_val = item.text().upper() self._project.equipment[row] = eq.model_copy(update={"tag_prefix": new_val}) item.setText(new_val) elif col == 2: self._project.equipment[row] = eq.model_copy(update={"display_name": item.text()}) elif col == 3: self._project.equipment[row] = eq.model_copy(update={"model_ref": item.text()}) elif col == 4: self._project.equipment[row] = eq.model_copy( update={"system_id": SystemId(item.text())} ) elif col in (5, 6, 7): x = float(self._table.item(row, 5).text()) y = float(self._table.item(row, 6).text()) z = float(self._table.item(row, 7).text()) self._project.equipment[row] = eq.model_copy( update={"location": ShipCoord(x_pp=x, y_cl=y, z_bl=z)} ) elif col == 8: deck = item.text().strip() or None self._project.equipment[row] = eq.model_copy(update={"deck_id": deck}) except Exception as exc: QMessageBox.warning(self, "Edición inválida", str(exc)) self._refresh_table() return self._project.touch() self.projectMutated.emit(self._project) def _on_add(self) -> None: if self._project is None: return dlg = _AddEquipmentDialog(self._project, self) if dlg.exec(): eq = dlg.build() if eq is None: return try: new_list = [*self._project.equipment, eq] updated = self._project.model_copy(update={"equipment": new_list}) # Re-construir para revalidar via Pydantic updated = updated.__class__.model_validate(updated.model_dump()) self._project = updated except Exception as exc: QMessageBox.critical(self, "Inválido", str(exc)) return self._refresh_table() self.projectMutated.emit(self._project) def _on_remove(self) -> None: if self._project is None: return row = self._table.currentRow() if row < 0 or row >= len(self._project.equipment): return eq = self._project.equipment[row] confirm = QMessageBox.question( self, "Eliminar equipo", f"¿Eliminar '{eq.tag_prefix} · {eq.display_name}'?", ) if confirm != QMessageBox.StandardButton.Yes: return new_list = [e for i, e in enumerate(self._project.equipment) if i != row] try: updated = self._project.model_copy(update={"equipment": new_list}) updated = updated.__class__.model_validate(updated.model_dump()) self._project = updated except Exception as exc: QMessageBox.critical(self, "Error", str(exc)) return self._refresh_table() self.projectMutated.emit(self._project) class _AddEquipmentDialog(QDialog): """Diálogo modal para agregar un Equipment.""" def __init__(self, project: Project, parent: QWidget | None = None) -> None: super().__init__(parent) self._project = project self.setWindowTitle("Agregar equipo") self.setMinimumWidth(420) layout = QVBoxLayout(self) form = QFormLayout() self._tag_prefix = QLineEdit() self._tag_prefix.setPlaceholderText("ME_PORT, GEN_2, BILGE_FWD...") form.addRow("Tag prefix:", self._tag_prefix) self._display = QLineEdit() self._display.setPlaceholderText("Motor principal babor") form.addRow("Nombre:", self._display) self._model_ref = QComboBox() try: lib = load_library() for em in lib.equipment_models: self._model_ref.addItem(f"{em.id} · {em.manufacturer} {em.model_name}", em.id) except Exception: pass form.addRow("Modelo (biblioteca):", self._model_ref) self._system = QComboBox() for sys_id in project.systems_enabled: self._system.addItem(sys_id.value, sys_id.value) if self._system.count() == 0: for sys_id in SystemId: self._system.addItem(sys_id.value, sys_id.value) form.addRow("Sistema:", self._system) # Location self._x = QDoubleSpinBox() self._x.setRange(-5.0, 200.0) self._x.setDecimals(2) self._x.setSuffix(" m") self._x.setValue(project.vessel.length_overall_m * 0.3) form.addRow("x_pp:", self._x) self._y = QDoubleSpinBox() self._y.setRange(-30.0, 30.0) self._y.setDecimals(2) self._y.setSuffix(" m") form.addRow("y_cl:", self._y) self._z = QDoubleSpinBox() self._z.setRange(-10.0, 50.0) self._z.setDecimals(2) self._z.setSuffix(" m") self._z.setValue(1.0) form.addRow("z_bl:", self._z) self._deck = QComboBox() self._deck.addItem("(sin asignar)", None) for d in project.vessel.decks: self._deck.addItem(f"{d.id} · {d.name}", d.id) form.addRow("Deck:", self._deck) layout.addLayout(form) buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) def build(self) -> Equipment | None: prefix = self._tag_prefix.text().strip().upper() if not prefix: return None try: return Equipment( id=f"eq_{uuid.uuid4().hex[:8]}_{prefix.lower()}", model_ref=self._model_ref.currentData() or "", tag_prefix=prefix, display_name=self._display.text() or prefix, location=ShipCoord( x_pp=self._x.value(), y_cl=self._y.value(), z_bl=self._z.value() ), deck_id=self._deck.currentData(), system_id=SystemId(self._system.currentData() or "main_engine"), ) except Exception: return None