diff --git a/pyproject.toml b/pyproject.toml index 18da3f86..854c8f92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -137,12 +137,16 @@ include = [ "src/setuav_studio/ui/widget/spinbox.py", "src/setuav_studio/ui/widget/table.py", "src/setuav_studio/ui/icons.py", - "src/setuav_studio/ui/log/buffer.py", - "src/setuav_studio/ui/log/window.py", + "src/setuav_studio/ui/dialog/about.py", + "src/setuav_studio/ui/dialog/expression_dialog.py", + "src/setuav_studio/ui/dialog/log.py", + "src/setuav_studio/ui/dialog/plugin_manager.py", + "src/setuav_studio/ui/dialog/task_monitor.py", "src/setuav_studio/ui/editor/component.py", + "src/setuav_studio/ui/editor/constraint.py", "src/setuav_studio/ui/editor/envelope.py", - "src/setuav_studio/ui/editor/instance.py", "src/setuav_studio/ui/editor/mass.py", + "src/setuav_studio/ui/editor/parameter.py", "src/setuav_studio/ui/editor/transform.py", "src/setuav_studio/api/__init__.py", "src/setuav_studio/api/api.py", @@ -172,11 +176,6 @@ include = [ "src/setuav_studio/project/__init__.py", "src/setuav_studio/project/document.py", "src/setuav_studio/project/validation.py", - "src/setuav_studio/ui/configuration/bar.py", - "src/setuav_studio/ui/configuration/dialog.py", - "src/setuav_studio/ui/constraint/constraints_dialog.py", - "src/setuav_studio/ui/constraint/status.py", - "src/setuav_studio/ui/parameter/panel.py", "src/setuav_studio/ui/theme.py", ] pythonVersion = "3.11" diff --git a/src/setuav_studio/__main__.py b/src/setuav_studio/__main__.py index 740e55ee..903e35d2 100644 --- a/src/setuav_studio/__main__.py +++ b/src/setuav_studio/__main__.py @@ -77,8 +77,8 @@ def main() -> int: from PySide6.QtWidgets import QApplication from setuav_studio.api import PluginManager, StudioAPI + from setuav_studio.ui.dialog.log import install_log_buffer from setuav_studio.ui.icons import application_icon - from setuav_studio.ui.log.buffer import install_log_buffer from setuav_studio.ui.settings.settings_pages import StudioSettings from setuav_studio.ui.shell import MainWindow from setuav_studio.ui.theme import apply_theme diff --git a/src/setuav_studio/api/manager.py b/src/setuav_studio/api/manager.py index 54465dc6..f144cc97 100644 --- a/src/setuav_studio/api/manager.py +++ b/src/setuav_studio/api/manager.py @@ -290,6 +290,7 @@ def _load_plugin_from_file( spec = importlib.util.spec_from_file_location(f"user_plugins.{name}", file_path) if spec and spec.loader: mod = importlib.util.module_from_spec(spec) + sys.modules[f"user_plugins.{name}"] = mod spec.loader.exec_module(mod) return self._find_plugin_in_module(mod) return None diff --git a/src/setuav_studio/ui/configuration/__init__.py b/src/setuav_studio/ui/configuration/__init__.py deleted file mode 100644 index 01746c74..00000000 --- a/src/setuav_studio/ui/configuration/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Configuration management UI components.""" - -from __future__ import annotations - -from setuav_studio.ui.configuration.bar import ConfigurationToolBar -from setuav_studio.ui.configuration.dialog import ( - ConfigurationEditDialog, - ManageConfigurationsDialog, -) - -__all__ = [ - "ConfigurationEditDialog", - "ConfigurationToolBar", - "ManageConfigurationsDialog", -] diff --git a/src/setuav_studio/ui/configuration/bar.py b/src/setuav_studio/ui/configuration/bar.py deleted file mode 100644 index 987fe9d1..00000000 --- a/src/setuav_studio/ui/configuration/bar.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Clean widget for selecting and managing active configurations in the Project Explorer.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from PySide6.QtCore import Signal -from PySide6.QtWidgets import ( - QComboBox, - QDialog, - QHBoxLayout, - QToolButton, - QWidget, -) - -from setuav_studio.model.configuration import ConfigurationManager -from setuav_studio.ui.configuration.dialog import ( - ConfigurationEditDialog, - ManageConfigurationsDialog, -) -from setuav_studio.ui.icons import get_icon - -if TYPE_CHECKING: - from setuav_studio_sdk import StudioAPI - - -class ConfigurationSelectorWidget(QWidget): - """Clean widget for selecting and managing active project configurations.""" - - configuration_changed = Signal(object) # active_id: str | None - - def __init__(self, api: StudioAPI, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setObjectName("studio.configuration_selector") - self._api = api - self._manager: ConfigurationManager | None = None - - layout = QHBoxLayout(self) - layout.setContentsMargins(2, 2, 2, 2) - layout.setSpacing(4) - - self.config_combo = QComboBox(self) - self.config_combo.setObjectName("studio.configuration_combo") - self.config_combo.setToolTip("Active Configuration") - self.config_combo.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents) - self.config_combo.activated.connect(self._on_combo_activated) - layout.addWidget(self.config_combo, 1) - - self.manage_button = QToolButton(self) - self.manage_button.setIcon(get_icon("settings")) - self.manage_button.setToolTip("Manage Configurations…") - self.manage_button.setStatusTip("Manage Configurations…") - self.manage_button.setAccessibleName("Manage Configurations…") - self.manage_button.setAutoRaise(True) - self.manage_button.setFixedSize(26, 26) - self.manage_button.clicked.connect(self._manage_configs) - layout.addWidget(self.manage_button) - - # Listen to project events - self._api.on_project_changed(self._on_project_changed) - self._api.on_project_content_changed(self._on_project_content_changed) - - if self._api.current_project is not None: - self._on_project_changed(self._api.current_project) - - @property - def manager(self) -> ConfigurationManager | None: - return self._manager - - def _on_project_changed(self, project) -> None: - if project is not None: - self._manager = project.get_configuration_manager() - if self._manager is not None: - self._manager.add_change_listener(self._refresh_combo) - else: - self._manager = None - self._refresh_combo() - - def _on_project_content_changed(self, project) -> None: - if project is not None: - self._manager = project.get_configuration_manager() - if self._manager is not None: - self._manager.add_change_listener(self._refresh_combo) - self._refresh_combo() - - def _refresh_combo(self) -> None: - self.config_combo.blockSignals(True) - try: - self.config_combo.clear() - if self._manager is None: - self.config_combo.addItem("[No Project]", None) - self.config_combo.setEnabled(False) - self.manage_button.setEnabled(False) - return - - # Base configuration - self.config_combo.addItem("[Base Configuration]", None) - - configs = self._manager.get_configurations() - active_id = self._manager.get_active_id() - active_idx = 0 - - for i, cfg in enumerate(configs): - tag = cfg.get("tag", "").strip() - name = cfg.get("name", "").strip() - label = f"[{tag}] {name}" if tag else name - cid = cfg.get("id") - self.config_combo.addItem(label, cid) - if str(cid) == str(active_id): - active_idx = i + 1 # offset by 1 for [Base] - - # Separator and management action - self.config_combo.insertSeparator(self.config_combo.count()) - self.config_combo.addItem("New Configuration…", "__new__") - - self.config_combo.setCurrentIndex(active_idx) - self.config_combo.setEnabled(True) - self.manage_button.setEnabled(True) - finally: - self.config_combo.blockSignals(False) - - def _on_combo_activated(self, index: int) -> None: - if self._manager is None: - return - - data = self.config_combo.itemData(index) - - if data == "__new__": - self._create_new_config() - return - - if data == "__manage__": - self._manage_configs() - return - - # Normal configuration switch - self._manager.set_active_id(data) - if self._api.current_project is not None: - stack = getattr(self._api.current_project, "undo_stack", None) - if stack is not None and hasattr(stack, "clear"): - stack.clear() - self.configuration_changed.emit(data) - self._api.notify_project_content_changed() - - def _create_new_config(self) -> None: - if self._manager is None: - return - dlg = ConfigurationEditDialog(self) - if dlg.exec() == QDialog.DialogCode.Accepted: - data = dlg.get_data() - new_cfg = self._manager.create_configuration(**data) - self._manager.set_active_id(new_cfg["id"]) - if self._api.current_project is not None: - stack = getattr(self._api.current_project, "undo_stack", None) - if stack is not None and hasattr(stack, "clear"): - stack.clear() - self._refresh_combo() - self._api.notify_project_content_changed() - else: - self._refresh_combo() - - def _manage_configs(self) -> None: - if self._manager is None: - return - dlg = ManageConfigurationsDialog(self._manager, self._api, self) - dlg.exec() - if self._api.current_project is not None: - stack = getattr(self._api.current_project, "undo_stack", None) - if stack is not None and hasattr(stack, "clear"): - stack.clear() - self._refresh_combo() - self._api.notify_project_content_changed() - - -# Backward compatibility alias -ConfigurationToolBar = ConfigurationSelectorWidget diff --git a/src/setuav_studio/ui/configuration/dialog.py b/src/setuav_studio/ui/configuration/dialog.py deleted file mode 100644 index e9ea75bd..00000000 --- a/src/setuav_studio/ui/configuration/dialog.py +++ /dev/null @@ -1,223 +0,0 @@ -"""Dialogs for creating, editing, and managing project configurations.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QAbstractItemView, - QDialog, - QDialogButtonBox, - QFormLayout, - QHBoxLayout, - QHeaderView, - QLineEdit, - QMessageBox, - QPushButton, - QTableWidget, - QTableWidgetItem, - QVBoxLayout, - QWidget, -) - -from setuav_studio.ui.icons import get_icon - -if TYPE_CHECKING: - from setuav_studio.model.configuration import ConfigurationManager - from setuav_studio_sdk import StudioAPI - - -class ConfigurationEditDialog(QDialog): - """Dialog to create or edit a single configuration.""" - - def __init__( - self, - parent: QWidget | None = None, - config: dict[str, Any] | None = None, - ) -> None: - super().__init__(parent) - self.setWindowTitle("Edit Configuration" if config else "New Configuration") - self.resize(380, 200) - - self._config = config or {} - layout = QVBoxLayout(self) - - form = QFormLayout() - form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) - - self.name_edit = QLineEdit(self._config.get("name", "")) - self.name_edit.setPlaceholderText("e.g. Cruise Configuration") - form.addRow("Name:", self.name_edit) - - self.tag_edit = QLineEdit(self._config.get("tag", "")) - self.tag_edit.setPlaceholderText("e.g. CRZ") - self.tag_edit.setMaxLength(6) - form.addRow("Tag:", self.tag_edit) - - self.desc_edit = QLineEdit(self._config.get("description", "")) - self.desc_edit.setPlaceholderText("Optional description") - form.addRow("Description:", self.desc_edit) - - layout.addLayout(form) - - button_box = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel - ) - button_box.accepted.connect(self._validate_and_accept) - button_box.rejected.connect(self.reject) - layout.addWidget(button_box) - - def _validate_and_accept(self) -> None: - name = self.name_edit.text().strip() - tag = self.tag_edit.text().strip().upper() - if not name: - QMessageBox.warning(self, "Invalid Name", "Please enter a configuration name.") - self.name_edit.setFocus() - return - if not tag: - QMessageBox.warning(self, "Invalid Tag", "Please enter a short tag (1-6 letters).") - self.tag_edit.setFocus() - return - self.accept() - - def get_data(self) -> dict[str, Any]: - return { - "name": self.name_edit.text().strip(), - "tag": self.tag_edit.text().strip().upper(), - "description": self.desc_edit.text().strip(), - } - - -class ManageConfigurationsDialog(QDialog): - """Dialog to list, add, edit, and delete project configurations.""" - - def __init__( - self, - manager: ConfigurationManager, - api: StudioAPI, - parent: QWidget | None = None, - ) -> None: - super().__init__(parent) - self.manager = manager - self.api = api - self.setWindowTitle("Manage Configurations") - self.resize(560, 340) - - layout = QVBoxLayout(self) - - # Table of configurations - self.table = QTableWidget(0, 3) - self.table.setHorizontalHeaderLabels(["Tag", "Name", "Description"]) - self.table.horizontalHeader().setSectionResizeMode( - 0, QHeaderView.ResizeMode.ResizeToContents - ) - self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive) - self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) - self.table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) - self.table.setWordWrap(True) - self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) - self.table.itemSelectionChanged.connect(self._update_button_states) - self.table.itemDoubleClicked.connect(self._edit_selected) - layout.addWidget(self.table) - - # Buttons - btn_layout = QHBoxLayout() - self.btn_new = QPushButton(get_icon("file_new"), "New…") - self.btn_new.clicked.connect(self._create_new) - btn_layout.addWidget(self.btn_new) - - self.btn_edit = QPushButton("Edit…") - self.btn_edit.clicked.connect(self._edit_selected) - btn_layout.addWidget(self.btn_edit) - - self.btn_delete = QPushButton("Delete") - self.btn_delete.clicked.connect(self._delete_selected) - btn_layout.addWidget(self.btn_delete) - - btn_layout.addStretch() - - self.btn_close = QPushButton("Close") - self.btn_close.clicked.connect(self.accept) - btn_layout.addWidget(self.btn_close) - - layout.addLayout(btn_layout) - self._refresh_table() - - def _refresh_table(self) -> None: - configs = self.manager.get_configurations() - self.table.setRowCount(len(configs)) - for row, cfg in enumerate(configs): - tag_item = QTableWidgetItem(cfg.get("tag", "")) - tag_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) - tag_item.setFlags(tag_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - - name_item = QTableWidgetItem(cfg.get("name", "")) - name_item.setFlags(name_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - - desc_item = QTableWidgetItem(cfg.get("description", "")) - desc_item.setFlags(desc_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - - self.table.setItem(row, 0, tag_item) - self.table.setItem(row, 1, name_item) - self.table.setItem(row, 2, desc_item) - - self.table.resizeRowsToContents() - self._update_button_states() - - def _selected_config_id(self) -> str | None: - selected_rows = self.table.selectionModel().selectedRows() - if not selected_rows: - return None - row = selected_rows[0].row() - configs = self.manager.get_configurations() - if 0 <= row < len(configs): - return configs[row].get("id") - return None - - def _update_button_states(self) -> None: - has_selection = len(self.table.selectionModel().selectedRows()) > 0 - self.btn_edit.setEnabled(has_selection) - self.btn_delete.setEnabled(has_selection) - - def _create_new(self) -> None: - dlg = ConfigurationEditDialog(self) - if dlg.exec() == QDialog.DialogCode.Accepted: - data = dlg.get_data() - self.manager.create_configuration(**data) - self._refresh_table() - self.api.notify_project_content_changed() - - def _edit_selected(self) -> None: - cid = self._selected_config_id() - if not cid: - return - cfg = self.manager.get_configuration(cid) - if not cfg: - return - dlg = ConfigurationEditDialog(self, config=cfg) - if dlg.exec() == QDialog.DialogCode.Accepted: - data = dlg.get_data() - self.manager.update_configuration(cid, **data) - self._refresh_table() - self.api.notify_project_content_changed() - - def _delete_selected(self) -> None: - cid = self._selected_config_id() - if not cid: - return - cfg = self.manager.get_configuration(cid) - name = cfg.get("name", cid) if cfg else cid - - ans = QMessageBox.question( - self, - "Delete Configuration", - f"Are you sure you want to delete configuration '{name}'?\nThis cannot be undone.", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - if ans == QMessageBox.StandardButton.Yes: - self.manager.delete_configuration(cid) - self._refresh_table() - self.api.notify_project_content_changed() diff --git a/src/setuav_studio/ui/constraint/__init__.py b/src/setuav_studio/ui/constraint/__init__.py deleted file mode 100644 index a50eb285..00000000 --- a/src/setuav_studio/ui/constraint/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Design constraints UI components.""" - -from __future__ import annotations - -from setuav_studio.ui.constraint.constraints_dialog import ( - ConstraintEditDialog, - ManageConstraintsDialog, -) -from setuav_studio.ui.constraint.editor import ConstraintPropertyEditor -from setuav_studio.ui.constraint.status import ConstraintStatusWidget - -__all__ = [ - "ConstraintEditDialog", - "ConstraintPropertyEditor", - "ConstraintStatusWidget", - "ManageConstraintsDialog", -] diff --git a/src/setuav_studio/ui/constraint/constraints_dialog.py b/src/setuav_studio/ui/constraint/constraints_dialog.py deleted file mode 100644 index e029e6e6..00000000 --- a/src/setuav_studio/ui/constraint/constraints_dialog.py +++ /dev/null @@ -1,393 +0,0 @@ -"""Dialogs for editing and managing project design constraints.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QCheckBox, - QComboBox, - QDialog, - QDialogButtonBox, - QFormLayout, - QHBoxLayout, - QHeaderView, - QLabel, - QLineEdit, - QMessageBox, - QPushButton, - QTableWidget, - QTableWidgetItem, - QTextEdit, - QVBoxLayout, - QWidget, -) - -from setuav_studio.model.constraint import ConstraintChecker, ConstraintResult -from setuav_studio.ui.icons import get_icon -from setuav_studio.ui.theme import status_color - -if TYPE_CHECKING: - from setuav_studio_sdk import StudioAPI - - -class ConstraintEditDialog(QDialog): - """Dialog for creating or editing a single design constraint rule.""" - - def __init__( - self, - parent: QWidget | None = None, - initial_data: dict[str, Any] | None = None, - checker: ConstraintChecker | None = None, - project_data: dict[str, Any] | None = None, - api: StudioAPI | None = None, - ) -> None: - super().__init__(parent) - self.setWindowTitle("Edit Constraint" if initial_data else "New Constraint") - self.resize(520, 360) - - self._api = api - self._checker = checker or ConstraintChecker() - self._project_data = project_data or {} - self._initial = initial_data or {} - - layout = QVBoxLayout(self) - form = QFormLayout() - - self.name_edit = QLineEdit(self) - self.name_edit.setText(self._initial.get("name", "")) - self.name_edit.setPlaceholderText("e.g. Wing Loading Limit") - form.addRow("Name:", self.name_edit) - - expr_layout = QHBoxLayout() - self.expr_edit = QLineEdit(self) - self.expr_edit.setText(self._initial.get("expression", "")) - self.expr_edit.setPlaceholderText("e.g. mtow / 1000 / wing_area <= 50") - self.expr_edit.textChanged.connect(self._test_expression) - expr_layout.addWidget(self.expr_edit) - - self.btn_expr_fx = QPushButton("fx", self) - self.btn_expr_fx.setToolTip("Open Equation / Expression Assistant") - self.btn_expr_fx.setFixedWidth(32) - self.btn_expr_fx.clicked.connect(self._open_expression_assistant) - expr_layout.addWidget(self.btn_expr_fx) - form.addRow("Expression:", expr_layout) - - self.severity_combo = QComboBox(self) - self.severity_combo.addItems(["warning", "error", "info"]) - curr_sev = self._initial.get("severity", "warning") - idx = self.severity_combo.findText(curr_sev) - if idx >= 0: - self.severity_combo.setCurrentIndex(idx) - form.addRow("Severity:", self.severity_combo) - - self.message_edit = QLineEdit(self) - self.message_edit.setText(self._initial.get("message", "")) - self.message_edit.setPlaceholderText("Message when violated (optional)") - form.addRow("Violation Message:", self.message_edit) - - self.desc_edit = QTextEdit(self) - self.desc_edit.setPlainText(self._initial.get("description", "")) - self.desc_edit.setPlaceholderText("Explanation / rationale for this constraint...") - self.desc_edit.setMaximumHeight(80) - form.addRow("Description:", self.desc_edit) - - self.enabled_check = QCheckBox("Enable constraint", self) - self.enabled_check.setChecked(self._initial.get("enabled", True)) - form.addRow("", self.enabled_check) - - layout.addLayout(form) - - # Live Evaluation Result Label - self.preview_label = QLabel(self) - self.preview_label.setTextFormat(Qt.TextFormat.RichText) - layout.addWidget(self.preview_label) - - self._test_expression() - - # Buttons - self.button_box = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, - self, - ) - self.button_box.accepted.connect(self._validate_and_accept) - self.button_box.rejected.connect(self.reject) - layout.addWidget(self.button_box) - - def _open_expression_assistant(self) -> None: - if self._api is None: - return - from setuav_studio.ui.parameter.expression_dialog import AdvancedExpressionDialog - - dlg = AdvancedExpressionDialog( - self._api, - initial_expression=self.expr_edit.text(), - title="Constraint Expression Assistant", - is_boolean_constraint=True, - parent=self, - ) - if dlg.exec() == QDialog.DialogCode.Accepted: - self.expr_edit.setText(dlg.get_expression()) - - def _test_expression(self) -> None: - expr = self.expr_edit.text().strip() - if not expr: - self.preview_label.setText("Enter an expression to evaluate") - return - - mock_constraint = { - "id": "preview", - "name": "preview", - "expression": expr, - "enabled": True, - } - res = self._checker.check_constraint(mock_constraint, self._project_data) - if res.error: - self.preview_label.setText( - f"❌ Error: {res.error}" - ) - elif res.passed: - self.preview_label.setText( - f"✔ Condition Passed" - ) - else: - self.preview_label.setText( - f"⚠ Condition Violated (Evaluates to False)" - ) - - def _validate_and_accept(self) -> None: - name = self.name_edit.text().strip() - expr = self.expr_edit.text().strip() - if not name: - QMessageBox.warning(self, "Validation Error", "Constraint name is required.") - self.name_edit.setFocus() - return - if not expr: - QMessageBox.warning(self, "Validation Error", "Expression is required.") - self.expr_edit.setFocus() - return - self.accept() - - def get_data(self) -> dict[str, Any]: - cid = self._initial.get("id", "") - if not cid: - cid = self.name_edit.text().strip().lower().replace(" ", "_") - return { - "id": cid, - "name": self.name_edit.text().strip(), - "expression": self.expr_edit.text().strip(), - "severity": self.severity_combo.currentText(), - "message": self.message_edit.text().strip(), - "description": self.desc_edit.toPlainText().strip(), - "enabled": self.enabled_check.isChecked(), - } - - -class ManageConstraintsDialog(QDialog): - """Dialog for viewing, adding, editing, and deleting project constraints.""" - - def __init__( - self, - api: StudioAPI, - checker: ConstraintChecker | None = None, - parent: QWidget | None = None, - ) -> None: - super().__init__(parent) - self.setWindowTitle("Manage Design Constraints") - self.resize(750, 420) - - self._api = api - self._checker = checker or ConstraintChecker() - - layout = QVBoxLayout(self) - - # Table - self.table = QTableWidget(0, 5, self) - self.table.setHorizontalHeaderLabels( - ["Status", "Name", "Expression", "Severity", "Enabled"] - ) - self.table.horizontalHeader().setSectionResizeMode( - 0, QHeaderView.ResizeMode.ResizeToContents - ) - self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive) - self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) - self.table.horizontalHeader().setSectionResizeMode( - 3, QHeaderView.ResizeMode.ResizeToContents - ) - self.table.horizontalHeader().setSectionResizeMode( - 4, QHeaderView.ResizeMode.ResizeToContents - ) - self.table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) - self.table.setWordWrap(True) - self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) - self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) - self.table.doubleClicked.connect(self._on_edit) - layout.addWidget(self.table) - - # Action Buttons - btn_layout = QHBoxLayout() - self.btn_add = QPushButton("Add Constraint…", self) - self.btn_add.setIcon(get_icon("file_new")) - self.btn_add.clicked.connect(self._on_add) - btn_layout.addWidget(self.btn_add) - - self.btn_edit = QPushButton("Edit…", self) - self.btn_edit.setIcon(get_icon("settings")) - self.btn_edit.clicked.connect(self._on_edit) - btn_layout.addWidget(self.btn_edit) - - self.btn_delete = QPushButton("Delete", self) - self.btn_delete.setIcon(get_icon("delete")) - self.btn_delete.clicked.connect(self._on_delete) - btn_layout.addWidget(self.btn_delete) - - btn_layout.addStretch() - - self.btn_close = QPushButton("Close", self) - self.btn_close.clicked.connect(self.accept) - btn_layout.addWidget(self.btn_close) - - layout.addLayout(btn_layout) - - self._refresh_table() - - def _get_project_data(self) -> dict[str, Any]: - return self._api.current_project.data if self._api.current_project else {} - - def _refresh_table(self) -> None: - self.table.setRowCount(0) - project_data = self._get_project_data() - constraints = project_data.get("constraints", []) - if not isinstance(constraints, list): - return - - results = self._checker.check_all(project_data, api=self._api) - res_map: dict[str, ConstraintResult] = {r.id: r for r in results} - - for row, c in enumerate(constraints): - if not isinstance(c, dict): - continue - cid = c.get("id", "") - name = c.get("name", "") - expr = c.get("expression", "") - severity = c.get("severity", "warning") - enabled = c.get("enabled", True) - - self.table.insertRow(row) - - # Status Icon/Text - res = res_map.get(cid) - if not enabled: - status_text = "⚪ Disabled" - color = "#888888" - elif res and res.error: - status_text = "❌ Error" - color = status_color("error") - elif res and not res.passed: - status_text = "⚠ Violated" - color = status_color("warning") - else: - status_text = "✔ Passed" - color = status_color("success") - - status_item = QTableWidgetItem(status_text) - status_item.setForeground( - Qt.GlobalColor.white if color == "" else Qt.GlobalColor.yellow - ) - status_item.setData(Qt.ItemDataRole.UserRole, cid) - status_item.setFlags(status_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - - name_item = QTableWidgetItem(name) - name_item.setFlags(name_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - - expr_item = QTableWidgetItem(expr) - expr_item.setFlags(expr_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - - sev_item = QTableWidgetItem(severity.capitalize()) - sev_item.setFlags(sev_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - - en_item = QTableWidgetItem("Yes" if enabled else "No") - en_item.setFlags(en_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - - self.table.setItem(row, 0, status_item) - self.table.setItem(row, 1, name_item) - self.table.setItem(row, 2, expr_item) - self.table.setItem(row, 3, sev_item) - self.table.setItem(row, 4, en_item) - - self.table.resizeRowsToContents() - - def _on_add(self) -> None: - dlg = ConstraintEditDialog( - self, - checker=self._checker, - project_data=self._get_project_data(), - api=self._api, - ) - if dlg.exec() == QDialog.DialogCode.Accepted: - data = dlg.get_data() - - def _apply() -> None: - pdata = self._get_project_data() - constraints = pdata.setdefault("constraints", []) - constraints.append(data) - - self._api.edit_project(f"Add constraint '{data['name']}'", _apply) - self._refresh_table() - - def _on_edit(self) -> None: - row = self.table.currentRow() - if row < 0: - return - status_item = self.table.item(row, 0) - if not status_item: - return - cid = status_item.data(Qt.ItemDataRole.UserRole) - pdata = self._get_project_data() - constraints = pdata.get("constraints", []) - idx = next((i for i, c in enumerate(constraints) if c.get("id") == cid), None) - if idx is None: - return - - target_c = dict(constraints[idx]) - dlg = ConstraintEditDialog( - self, - initial_data=target_c, - checker=self._checker, - project_data=pdata, - api=self._api, - ) - if dlg.exec() == QDialog.DialogCode.Accepted: - updated = dlg.get_data() - - def _apply() -> None: - constraints[idx] = updated - - self._api.edit_project(f"Edit constraint '{updated['name']}'", _apply) - self._refresh_table() - - def _on_delete(self) -> None: - row = self.table.currentRow() - if row < 0: - return - status_item = self.table.item(row, 0) - if not status_item: - return - cid = status_item.data(Qt.ItemDataRole.UserRole) - - reply = QMessageBox.question( - self, - "Delete Constraint", - f"Are you sure you want to delete constraint '{cid}'?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - ) - if reply == QMessageBox.StandardButton.Yes: - - def _apply() -> None: - pdata = self._get_project_data() - constraints = pdata.get("constraints", []) - pdata["constraints"] = [c for c in constraints if c.get("id") != cid] - - self._api.edit_project(f"Delete constraint '{cid}'", _apply) - self._refresh_table() diff --git a/src/setuav_studio/ui/constraint/status.py b/src/setuav_studio/ui/constraint/status.py deleted file mode 100644 index 88896824..00000000 --- a/src/setuav_studio/ui/constraint/status.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Status bar widget displaying real-time project constraint evaluation status.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QHBoxLayout, QToolButton, QWidget - -from setuav_studio.model.constraint import ConstraintChecker -from setuav_studio.ui.constraint.constraints_dialog import ManageConstraintsDialog -from setuav_studio.ui.icons import get_icon -from setuav_studio.ui.theme import status_color - -if TYPE_CHECKING: - from setuav_studio_sdk import StudioAPI - - -class ConstraintStatusWidget(QWidget): - """Status bar indicator for real-time constraint validation.""" - - def __init__(self, api: StudioAPI, parent: QWidget | None = None) -> None: - super().__init__(parent) - self._api = api - self._checker = ConstraintChecker() - - layout = QHBoxLayout(self) - layout.setContentsMargins(4, 0, 4, 0) - layout.setSpacing(4) - - self.btn = QToolButton(self) - self.btn.setAutoRaise(True) - self.btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) - self.btn.clicked.connect(self._open_dialog) - layout.addWidget(self.btn) - - self._api.on_project_changed(self._on_project_updated) - self._api.on_project_content_changed(self._on_project_updated) - self.refresh() - - def _open_dialog(self) -> None: - if self._api.current_project is None: - return - dlg = ManageConstraintsDialog(self._api, self._checker, self) - dlg.exec() - self.refresh() - - def _on_project_updated(self, _project=None) -> None: - self.refresh() - - def refresh(self) -> None: - """Re-evaluate all constraints and update badge appearance.""" - if self._api.current_project is None: - self.btn.setText("Constraints") - self.btn.setIcon(get_icon("settings")) - self.btn.setToolTip("No active project") - self.btn.setEnabled(False) - return - - self.btn.setEnabled(True) - project_data = self._api.current_project.data - constraints = project_data.get("constraints", []) - if not constraints: - self.btn.setText("Constraints") - self.btn.setIcon(get_icon("settings")) - self.btn.setToolTip("No constraints configured (Click to add)") - self.btn.setStyleSheet("") - return - - results = self._checker.check_all(project_data, api=self._api) - violations = [r for r in results if r.enabled and (not r.passed or r.error)] - - if not violations: - self.btn.setText(f"✔ Constraints OK ({len(results)})") - self.btn.setIcon(get_icon("success") if callable(get_icon) else get_icon("settings")) - self.btn.setStyleSheet(f"color: {status_color('success')}; font-weight: bold;") - self.btn.setToolTip(f"All {len(results)} constraints satisfied.") - else: - errors = [v for v in violations if v.severity == "error" or v.error] - warn_color = status_color("error") if errors else status_color("warning") - self.btn.setText(f"⚠ {len(violations)} Violation{'s' if len(violations) > 1 else ''}") - self.btn.setStyleSheet(f"color: {warn_color}; font-weight: bold;") - - tooltip_lines = ["Constraint Violations:"] - for v in violations: - msg = v.error or v.message or v.expression - tooltip_lines.append(f"• {v.name}: {msg}") - tooltip_lines.append("
Click to manage constraints") - self.btn.setToolTip("
".join(tooltip_lines)) diff --git a/src/setuav_studio/ui/dialog/__init__.py b/src/setuav_studio/ui/dialog/__init__.py index b6453fbc..41fba30d 100644 --- a/src/setuav_studio/ui/dialog/__init__.py +++ b/src/setuav_studio/ui/dialog/__init__.py @@ -3,6 +3,29 @@ from __future__ import annotations from setuav_studio.ui.dialog.about import AboutDialog +from setuav_studio.ui.dialog.expression_dialog import AdvancedExpressionDialog +from setuav_studio.ui.dialog.log import ( + LogBridge, + LogEntry, + LogWindow, + clear_log_buffer, + install_log_buffer, + log_buffer_entries, + log_signal, +) from setuav_studio.ui.dialog.plugin_manager import PluginManagerDialog +from setuav_studio.ui.dialog.task_monitor import TaskMonitorDialog -__all__ = ["AboutDialog", "PluginManagerDialog"] +__all__ = [ + "AboutDialog", + "AdvancedExpressionDialog", + "LogBridge", + "LogEntry", + "LogWindow", + "PluginManagerDialog", + "TaskMonitorDialog", + "clear_log_buffer", + "install_log_buffer", + "log_buffer_entries", + "log_signal", +] diff --git a/src/setuav_studio/ui/parameter/expression_dialog.py b/src/setuav_studio/ui/dialog/expression_dialog.py similarity index 98% rename from src/setuav_studio/ui/parameter/expression_dialog.py rename to src/setuav_studio/ui/dialog/expression_dialog.py index 63a073e3..3ec5deee 100644 --- a/src/setuav_studio/ui/parameter/expression_dialog.py +++ b/src/setuav_studio/ui/dialog/expression_dialog.py @@ -198,10 +198,10 @@ def _update_token_completions(self, token: str) -> None: self._completer.setCompletionPrefix(token) def keyPressEvent(self, event: QKeyEvent) -> None: + popup = self._completer.popup() if self._completer else None if ( - self._completer - and self._completer.popup() - and self._completer.popup().isVisible() + popup + and popup.isVisible() and event.key() in ( Qt.Key.Key_Enter, @@ -221,21 +221,21 @@ def keyPressEvent(self, event: QKeyEvent) -> None: token, _start_pos, _end_pos = self._get_current_token() if not token: - if self._completer.popup(): - self._completer.popup().hide() + if popup: + popup.hide() return self._update_token_completions(token) popup = self._completer.popup() - if self._completer.completionCount() > 0: + if self._completer.completionCount() > 0 and popup: cr = self.cursorRect() + vbar = popup.verticalScrollBar() + vbar_w = vbar.sizeHint().width() if vbar else 0 cr.setWidth( max( 220, - self._completer.popup().sizeHintForColumn(0) - + self._completer.popup().verticalScrollBar().sizeHint().width() - + 30, + popup.sizeHintForColumn(0) + vbar_w + 30, ) ) self._completer.complete(cr) diff --git a/src/setuav_studio/ui/log/window.py b/src/setuav_studio/ui/dialog/log.py similarity index 69% rename from src/setuav_studio/ui/log/window.py rename to src/setuav_studio/ui/dialog/log.py index e4ad4d08..4ac8a006 100644 --- a/src/setuav_studio/ui/log/window.py +++ b/src/setuav_studio/ui/dialog/log.py @@ -1,6 +1,13 @@ +"""Log buffer and application logs dialog window.""" + from __future__ import annotations -from PySide6.QtCore import Qt +import logging +import time +from collections import deque +from typing import NamedTuple + +from PySide6.QtCore import QObject, Qt, Signal from PySide6.QtGui import QBrush, QColor from PySide6.QtWidgets import ( QAbstractItemView, @@ -11,9 +18,70 @@ QVBoxLayout, ) -from setuav_studio.ui.log.buffer import LogEntry, log_buffer_entries, log_signal from setuav_studio.ui.theme import status_color, tokens +MAX_BUFFER_SIZE = 2000 + + +class LogEntry(NamedTuple): + time: str + level: str + name: str + message: str + + +_buffer: deque[LogEntry] = deque(maxlen=MAX_BUFFER_SIZE) +_handler: logging.Handler | None = None + + +class LogBridge(QObject): + """Emits a Qt signal when a new log entry is captured, safe across threads.""" + + record_added = Signal(object) + + +_bridge = LogBridge() + + +class _LogBufferHandler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + try: + message = record.getMessage() + except Exception: + message = str(record.msg) + entry = LogEntry( + time=time.strftime("%H:%M:%S", time.localtime(record.created)), + level=record.levelname, + name=record.name, + message=message, + ) + _buffer.append(entry) + _bridge.record_added.emit(entry) + + +def install_log_buffer(level: int = logging.INFO) -> None: + """Attach an in-memory log buffer to the root logger (idempotent).""" + global _handler + root = logging.getLogger() + if _handler is None: + _handler = _LogBufferHandler() + root.addHandler(_handler) + _handler.setLevel(level) + + +def log_buffer_entries() -> list[LogEntry]: + """Return the captured log entries in chronological order.""" + return list(_buffer) + + +def log_signal() -> LogBridge: + """Return the Qt bridge that emits on each new log entry.""" + return _bridge + + +def clear_log_buffer() -> None: + _buffer.clear() + class LogWindow(QDialog): """Simple read-only window showing the application's captured logs as a table.""" diff --git a/src/setuav_studio/ui/task_monitor.py b/src/setuav_studio/ui/dialog/task_monitor.py similarity index 100% rename from src/setuav_studio/ui/task_monitor.py rename to src/setuav_studio/ui/dialog/task_monitor.py diff --git a/src/setuav_studio/ui/editor/__init__.py b/src/setuav_studio/ui/editor/__init__.py index 553baa6e..6d830472 100644 --- a/src/setuav_studio/ui/editor/__init__.py +++ b/src/setuav_studio/ui/editor/__init__.py @@ -3,15 +3,17 @@ from __future__ import annotations from setuav_studio.ui.editor.component import BaseComponentEditor +from setuav_studio.ui.editor.constraint import ConstraintPropertyEditor from setuav_studio.ui.editor.envelope import EnvelopeEditor -from setuav_studio.ui.editor.instance import InstanceEditor from setuav_studio.ui.editor.mass import MassPropertiesEditor +from setuav_studio.ui.editor.parameter import ParameterPropertyEditor from setuav_studio.ui.editor.transform import TransformEditor __all__ = [ "BaseComponentEditor", + "ConstraintPropertyEditor", "EnvelopeEditor", - "InstanceEditor", "MassPropertiesEditor", + "ParameterPropertyEditor", "TransformEditor", ] diff --git a/src/setuav_studio/ui/editor/component.py b/src/setuav_studio/ui/editor/component.py index 40ea9281..421ec04a 100644 --- a/src/setuav_studio/ui/editor/component.py +++ b/src/setuav_studio/ui/editor/component.py @@ -206,7 +206,7 @@ def _load_component(self) -> None: def _open_field_expression_assistant(self, field: ParameterField, current_val: str) -> None: from PySide6.QtWidgets import QDialog - from setuav_studio.ui.parameter.expression_dialog import AdvancedExpressionDialog + from setuav_studio.ui.dialog.expression_dialog import AdvancedExpressionDialog dlg = AdvancedExpressionDialog( self._api, diff --git a/src/setuav_studio/ui/constraint/editor.py b/src/setuav_studio/ui/editor/constraint.py similarity index 98% rename from src/setuav_studio/ui/constraint/editor.py rename to src/setuav_studio/ui/editor/constraint.py index e3c368f0..1ad8b17c 100644 --- a/src/setuav_studio/ui/constraint/editor.py +++ b/src/setuav_studio/ui/editor/constraint.py @@ -15,7 +15,6 @@ from setuav_studio.model.constraint import ConstraintChecker from setuav_studio.ui.icons import set_label_icon -from setuav_studio.ui.parameter.expression_dialog import AdvancedExpressionDialog from setuav_studio.ui.widget.table import PropertyTableMixin if TYPE_CHECKING: @@ -167,6 +166,8 @@ def _load_data(self) -> None: def _open_assistant(self, current_val: str) -> None: from PySide6.QtWidgets import QDialog + from setuav_studio.ui.dialog.expression_dialog import AdvancedExpressionDialog + dlg = AdvancedExpressionDialog( self._api, initial_expression=current_val, diff --git a/src/setuav_studio/ui/editor/instance.py b/src/setuav_studio/ui/editor/instance.py deleted file mode 100644 index 36259f66..00000000 --- a/src/setuav_studio/ui/editor/instance.py +++ /dev/null @@ -1,324 +0,0 @@ -from collections.abc import Callable -from typing import Any - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QAbstractItemView, - QApplication, - QComboBox, - QHBoxLayout, - QHeaderView, - QLabel, - QSizePolicy, - QTableWidget, - QTableWidgetItem, - QVBoxLayout, - QWidget, -) - -from setuav_studio.ui.icons import set_label_icon -from setuav_studio_sdk import StudioAPI - - -class InstanceEditor(QWidget): - def __init__(self, api: StudioAPI, instance: dict[str, Any]) -> None: - super().__init__() - self._api = api - self._instance = instance - self._loading = False - - layout = QVBoxLayout(self) - layout.setContentsMargins(6, 6, 6, 8) - layout.setSpacing(10) - - layout.addWidget(self._header("Instance", "instance")) - self.properties_table = self._table(["Property", "Value"]) - self.properties_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectItems) - self.properties_table.setEditTriggers( - QAbstractItemView.EditTrigger.DoubleClicked - | QAbstractItemView.EditTrigger.EditKeyPressed - | QAbstractItemView.EditTrigger.SelectedClicked - ) - self.properties_table.cellChanged.connect(self._update_property) - layout.addWidget(self.properties_table) - - layout.addWidget(self._header("Transform", "mdi6.axis-arrow")) - self.transform_table = QTableWidget(2, 3) - self.transform_table.setHorizontalHeaderLabels(["X", "Y", "Z"]) - self.transform_table.setVerticalHeaderLabels(["Position", "Rotation"]) - self.transform_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) - self.transform_table.horizontalHeader().setFixedHeight(23) - self.transform_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed) - self.transform_table.verticalHeader().setDefaultSectionSize(23) - self.transform_table.verticalHeader().setMinimumWidth(82) - self.transform_table.setAlternatingRowColors(True) - self.transform_table.setFixedHeight(71) - self.transform_table.cellChanged.connect(self._update_transform) - layout.addWidget(self.transform_table) - layout.addStretch() - - self._refresh() - - @staticmethod - def _header(text: str, icon_name: str | None = None) -> QWidget: - header = QWidget() - header.setProperty("sectionHeader", True) - header.setFixedHeight(20) - header_layout = QHBoxLayout(header) - header_layout.setContentsMargins(0, 0, 0, 0) - header_layout.setSpacing(5) - - if icon_name: - icon_label = QLabel() - set_label_icon(icon_label, icon_name) - icon_label.setFixedSize(14, 14) - header_layout.addWidget(icon_label) - - title_label = QLabel(text) - header_layout.addWidget(title_label) - header_layout.addStretch() - return header - - def _refresh(self) -> None: - self._loading = True - derivation = self._mapping(self._instance, "derivation") - derivation_type = str(derivation.get("type") or "copy") - definitions = [ - ("name", "Name", True), - ("id", "ID", False), - ("source", "Source", False), - ("parent", "Parent", False), - ("derivation_type", "Derivation", False), - ] - if derivation_type == "mirror": - definitions.extend( - [ - ("plane", "Mirror plane", False), - ("offset", "Mirror offset", True), - ] - ) - - for row in range(self.properties_table.rowCount()): - widget = self.properties_table.cellWidget(row, 1) - if widget is not None: - self.properties_table.removeCellWidget(row, 1) - widget.deleteLater() - self.properties_table.clearContents() - self.properties_table.setRowCount(len(definitions)) - for row, (key, label, editable) in enumerate(definitions): - label_item = QTableWidgetItem(label) - label_item.setData(Qt.ItemDataRole.UserRole, key) - label_item.setFlags(label_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - self.properties_table.setItem(row, 0, label_item) - - value_item = QTableWidgetItem(self._property_value(key, derivation)) - if not editable: - value_item.setFlags(value_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - self.properties_table.setItem(row, 1, value_item) - - self._set_combo( - "derivation_type", - derivation_type, - [("copy", "Copy"), ("mirror", "Mirror")], - self._change_derivation, - ) - if derivation_type == "mirror": - self._set_combo( - "plane", - str(derivation.get("plane") or "XZ"), - [("XY", "XY"), ("XZ", "XZ"), ("YZ", "YZ")], - self._change_plane, - ) - self._fit_height(self.properties_table) - self._set_transform_values() - self._loading = False - - def _property_value(self, key: str, derivation: dict[str, Any]) -> str: - if key == "source": - return self._component_name(self._instance.get("source")) - if key == "parent": - parent = self._instance.get("attach_to") or self._instance.get("parent") - return self._component_name(parent) if parent is not None else "—" - if key == "derivation_type": - return str(derivation.get("type") or "copy") - if key in {"plane", "offset"}: - return str(derivation.get(key) or ("XZ" if key == "plane" else 0)) - return str(self._instance.get(key) or "") - - def _component_name(self, component_id: object) -> str: - identifier = str(component_id or "") - project = self._api.current_project - if project is not None: - components = project.data.get("components", []) - if isinstance(components, list): - for component in components: - if not isinstance(component, dict): - continue - if str(component.get("id") or "") == identifier: - return str(component.get("name") or identifier) - return identifier - - def _update_property(self, row: int, column: int) -> None: - if self._loading or column != 1: - return - key = self._key(row) - item = self.properties_table.item(row, column) - value = item.text().strip() if item is not None else "" - if key == "name": - if not value: - self._refresh() - return - self._edit( - "Rename component instance", - lambda: self._instance.__setitem__("name", value), - ) - return - if key == "offset": - try: - offset = float(value) - except ValueError: - self._refresh() - return - self._edit( - "Edit mirror offset", - lambda: self._object(self._instance, "derivation").__setitem__("offset", offset), - ) - - def _change_derivation(self, value: str) -> None: - if self._loading or value not in {"copy", "mirror"}: - return - derivation = ( - {"type": "copy"} - if value == "copy" - else {"type": "mirror", "plane": "XZ", "offset": 0.0} - ) - self._edit( - "Change instance derivation", - lambda: self._instance.__setitem__("derivation", derivation), - ) - - def _change_plane(self, value: str) -> None: - if self._loading or value not in {"XY", "XZ", "YZ"}: - return - self._edit( - "Change mirror plane", - lambda: self._object(self._instance, "derivation").__setitem__("plane", value), - ) - - def _set_transform_values(self) -> None: - transform = self._mapping(self._instance, "transform") - position = self._mapping(transform, "position") - rotation = self._mapping(transform, "rotation") - values = ( - (position.get("x", 0), position.get("y", 0), position.get("z", 0)), - ( - rotation.get("roll", 0), - rotation.get("pitch", 0), - rotation.get("yaw", 0), - ), - ) - for row, row_values in enumerate(values): - for column, value in enumerate(row_values): - self.transform_table.setItem( - row, - column, - QTableWidgetItem(str(value or 0)), - ) - - def _update_transform(self, _row: int, _column: int) -> None: - if self._loading: - return - values: list[list[float]] = [] - try: - for row in range(2): - row_vals: list[float] = [] - for column in range(3): - item = self.transform_table.item(row, column) - if item is None: - raise ValueError("Missing table item") - row_vals.append(float(item.text())) - values.append(row_vals) - except (AttributeError, ValueError): - self._refresh() - return - - def change() -> None: - self._instance["transform"] = { - "position": dict(zip(("x", "y", "z"), values[0], strict=True)), - "rotation": dict(zip(("roll", "pitch", "yaw"), values[1], strict=True)), - } - - self._edit("Edit instance transform", change) - - def _edit(self, description: str, change: Callable[[], None]) -> None: - self._api.edit_component(self._instance, description, change) - self._refresh() - - def _set_combo( - self, - key: str, - value: str, - options: list[tuple[str, str]], - on_changed: Callable[[str], None], - ) -> None: - for row in range(self.properties_table.rowCount()): - if self._key(row) != key: - continue - item = self.properties_table.item(row, 1) - if item is not None: - item.setText("") - combo = QComboBox(self.properties_table) - combo.setFont(QApplication.font()) - combo.setSizePolicy( - QSizePolicy.Policy.Expanding, - QSizePolicy.Policy.Expanding, - ) - combo.view().setProperty("tableComboPopup", True) - combo.view().setFont(QApplication.font()) - for option_value, label in options: - combo.addItem(label, option_value) - combo.setCurrentIndex(max(combo.findData(value), 0)) - combo.currentIndexChanged.connect( - lambda _index, editor=combo, callback=on_changed: callback( - str(editor.currentData()) - ) - ) - self.properties_table.setCellWidget(row, 1, combo) - return - - def _key(self, row: int) -> str: - item = self.properties_table.item(row, 0) - return str(item.data(Qt.ItemDataRole.UserRole) or "") if item else "" - - @staticmethod - def _object(owner: dict[str, Any], key: str) -> dict[str, Any]: - value = owner.get(key) - if not isinstance(value, dict): - value = {} - owner[key] = value - return value - - @staticmethod - def _mapping(owner: dict[str, Any], key: str) -> dict[str, Any]: - value = owner.get(key) - return value if isinstance(value, dict) else {} - - @staticmethod - def _table(headers: list[str]) -> QTableWidget: - table = QTableWidget(0, len(headers)) - table.setHorizontalHeaderLabels(headers) - table.verticalHeader().setVisible(False) - table.verticalHeader().setDefaultSectionSize(22) - table.horizontalHeader().setFixedHeight(23) - table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) - table.setAlternatingRowColors(True) - table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) - return table - - @staticmethod - def _fit_height(table: QTableWidget) -> None: - table.setFixedHeight( - table.horizontalHeader().height() - + table.verticalHeader().defaultSectionSize() * table.rowCount() - + 2 - ) diff --git a/src/setuav_studio/ui/parameter/editor.py b/src/setuav_studio/ui/editor/parameter.py similarity index 99% rename from src/setuav_studio/ui/parameter/editor.py rename to src/setuav_studio/ui/editor/parameter.py index 5a7250de..3247a190 100644 --- a/src/setuav_studio/ui/parameter/editor.py +++ b/src/setuav_studio/ui/editor/parameter.py @@ -17,7 +17,6 @@ from setuav_studio.model.configuration import ConfigurationManager from setuav_studio.model.parameter import ParameterResolver from setuav_studio.ui.icons import set_label_icon -from setuav_studio.ui.parameter.expression_dialog import AdvancedExpressionDialog from setuav_studio.ui.widget.table import PropertyTableMixin if TYPE_CHECKING: @@ -190,6 +189,8 @@ def _load_data(self) -> None: def _open_assistant(self, current_val: str) -> None: from PySide6.QtWidgets import QDialog + from setuav_studio.ui.dialog.expression_dialog import AdvancedExpressionDialog + dlg = AdvancedExpressionDialog( self._api, initial_expression=current_val, diff --git a/src/setuav_studio/ui/log/__init__.py b/src/setuav_studio/ui/log/__init__.py deleted file mode 100644 index f1342273..00000000 --- a/src/setuav_studio/ui/log/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Logging UI buffer and window for Setuav Studio.""" - -from __future__ import annotations - -from setuav_studio.ui.log.buffer import ( - LogBridge, - LogEntry, - clear_log_buffer, - install_log_buffer, - log_buffer_entries, - log_signal, -) -from setuav_studio.ui.log.window import LogWindow - -__all__ = [ - "LogBridge", - "LogEntry", - "LogWindow", - "clear_log_buffer", - "install_log_buffer", - "log_buffer_entries", - "log_signal", -] diff --git a/src/setuav_studio/ui/log/buffer.py b/src/setuav_studio/ui/log/buffer.py deleted file mode 100644 index 38cf058f..00000000 --- a/src/setuav_studio/ui/log/buffer.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -import logging -import time -from collections import deque -from typing import NamedTuple - -from PySide6.QtCore import QObject, Signal - -MAX_BUFFER_SIZE = 2000 - - -class LogEntry(NamedTuple): - time: str - level: str - name: str - message: str - - -_buffer: deque[LogEntry] = deque(maxlen=MAX_BUFFER_SIZE) -_handler: logging.Handler | None = None - - -class LogBridge(QObject): - """Emits a Qt signal when a new log entry is captured, safe across threads.""" - - record_added = Signal(object) - - -_bridge = LogBridge() - - -class _LogBufferHandler(logging.Handler): - def emit(self, record: logging.LogRecord) -> None: - try: - message = record.getMessage() - except Exception: - message = str(record.msg) - entry = LogEntry( - time=time.strftime("%H:%M:%S", time.localtime(record.created)), - level=record.levelname, - name=record.name, - message=message, - ) - _buffer.append(entry) - _bridge.record_added.emit(entry) - - -def install_log_buffer(level: int = logging.INFO) -> None: - """Attach an in-memory log buffer to the root logger (idempotent).""" - global _handler - root = logging.getLogger() - if _handler is None: - _handler = _LogBufferHandler() - root.addHandler(_handler) - _handler.setLevel(level) - - -def log_buffer_entries() -> list[LogEntry]: - """Return the captured log entries in chronological order.""" - return list(_buffer) - - -def log_signal() -> LogBridge: - """Return the Qt bridge that emits on each new log entry.""" - return _bridge - - -def clear_log_buffer() -> None: - _buffer.clear() diff --git a/src/setuav_studio/ui/panels/__init__.py b/src/setuav_studio/ui/panels/__init__.py new file mode 100644 index 00000000..bb48b9ad --- /dev/null +++ b/src/setuav_studio/ui/panels/__init__.py @@ -0,0 +1,10 @@ +"""Built-in studio dock panels (Project Explorer, Properties).""" + +from setuav_studio.ui.panels.project_explorer import ProjectExplorer, ProjectExplorerPanel +from setuav_studio.ui.panels.properties import PropertiesPanel + +__all__ = [ + "ProjectExplorer", + "ProjectExplorerPanel", + "PropertiesPanel", +] diff --git a/src/setuav_studio/ui/panels/project_explorer/__init__.py b/src/setuav_studio/ui/panels/project_explorer/__init__.py new file mode 100644 index 00000000..2e6882f1 --- /dev/null +++ b/src/setuav_studio/ui/panels/project_explorer/__init__.py @@ -0,0 +1,8 @@ +"""Project explorer tree and panel widgets.""" + +from setuav_studio.ui.panels.project_explorer.panel import ( + ProjectExplorerPanel, +) +from setuav_studio.ui.panels.project_explorer.tree import ProjectExplorer + +__all__ = ["ProjectExplorer", "ProjectExplorerPanel"] diff --git a/src/setuav_studio/ui/project_explorer/context_menu.py b/src/setuav_studio/ui/panels/project_explorer/context_menu.py similarity index 86% rename from src/setuav_studio/ui/project_explorer/context_menu.py rename to src/setuav_studio/ui/panels/project_explorer/context_menu.py index e6296b86..bc718e06 100644 --- a/src/setuav_studio/ui/project_explorer/context_menu.py +++ b/src/setuav_studio/ui/panels/project_explorer/context_menu.py @@ -8,10 +8,10 @@ from setuav_studio.ui.icons import get_icon if TYPE_CHECKING: - from setuav_studio.ui.project_explorer.operations import ( + from setuav_studio.ui.panels.project_explorer.operations import ( ProjectExplorerOperations, ) - from setuav_studio.ui.project_explorer.tree import ProjectExplorer + from setuav_studio.ui.panels.project_explorer.tree import ProjectExplorer from setuav_studio_sdk import StudioAPI @@ -80,14 +80,9 @@ def open_constraints_group_menu(self, position: QPoint, can_edit: bool) -> None: menu = QMenu(self._tree) add_c_act = menu.addAction(get_icon("constraint"), "Add Constraint…") add_c_act.setEnabled(can_edit) - manage_c_act = menu.addAction(get_icon("constraint"), "Manage Constraints…") chosen = menu.exec(self._tree.viewport().mapToGlobal(position)) if chosen is add_c_act: self._ops.add_constraint_action() - elif chosen is manage_c_act: - from setuav_studio.ui.constraint.constraints_dialog import ManageConstraintsDialog - - ManageConstraintsDialog(self._api, parent=self._tree).exec() def open_parameter_element_menu( self, @@ -97,14 +92,10 @@ def open_parameter_element_menu( can_edit: bool, ) -> None: menu = QMenu(self._tree) - fx_act = menu.addAction(get_icon("settings"), "Edit with fx Assistant…") - fx_act.setEnabled(can_edit) del_act = menu.addAction(get_icon("remove"), "Delete") del_act.setEnabled(can_edit) chosen = menu.exec(self._tree.viewport().mapToGlobal(position)) - if chosen is fx_act: - self._ops.edit_parameter_fx(element) - elif chosen is del_act: + if chosen is del_act: self._ops.delete_item(item) def open_constraint_element_menu( @@ -115,16 +106,12 @@ def open_constraint_element_menu( can_edit: bool, ) -> None: menu = QMenu(self._tree) - fx_act = menu.addAction(get_icon("settings"), "Edit with fx Assistant…") - fx_act.setEnabled(can_edit) toggle_act = menu.addAction("Toggle Enabled") toggle_act.setEnabled(can_edit) del_act = menu.addAction(get_icon("remove"), "Delete") del_act.setEnabled(can_edit) chosen = menu.exec(self._tree.viewport().mapToGlobal(position)) - if chosen is fx_act: - self._ops.edit_constraint_fx(element) - elif chosen is toggle_act: + if chosen is toggle_act: self._ops.toggle_constraint(element) elif chosen is del_act: self._ops.delete_item(item) diff --git a/src/setuav_studio/ui/panels/project_explorer/dialog_actions.py b/src/setuav_studio/ui/panels/project_explorer/dialog_actions.py new file mode 100644 index 00000000..954bdc30 --- /dev/null +++ b/src/setuav_studio/ui/panels/project_explorer/dialog_actions.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from PySide6.QtWidgets import QWidget + +if TYPE_CHECKING: + from setuav_studio_sdk import StudioAPI + + +def add_parameter_action( + api: StudioAPI, + tree: QWidget, + is_constant: bool = False, + parent: QWidget | None = None, +) -> None: + data = api.current_project.data if api.current_project else {} + raw = data.get("parameters", {}) if isinstance(data, dict) else {} + prefix = "const_" if is_constant else "param_" + idx = 1 + while f"{prefix}{idx}" in raw: + idx += 1 + param_name = f"{prefix}{idx}" + + def _apply() -> None: + pdata = api.current_project.data if api.current_project else {} + pdata.setdefault("parameters", {})[param_name] = 0.0 + + action_name = "constant" if is_constant else "parameter" + api.edit_project(f"Add {action_name} '{param_name}'", _apply) + api.set_selection({"kind": "parameter", "key": param_name, "value": 0.0}) + + +def add_constraint_action( + api: StudioAPI, + tree: QWidget, + parent: QWidget | None = None, +) -> None: + data = api.current_project.data if api.current_project else {} + raw = data.get("constraints", []) if isinstance(data, dict) else [] + idx = 1 + existing_ids = {str(c.get("id")) for c in raw if isinstance(c, dict)} + while f"c_{idx}" in existing_ids: + idx += 1 + cid = f"c_{idx}" + cname = f"Constraint {idx}" + new_constraint = { + "id": cid, + "name": cname, + "expression": "span > 0", + "severity": "warning", + "enabled": True, + "description": "", + } + + def _apply() -> None: + pdata = api.current_project.data if api.current_project else {} + pdata.setdefault("constraints", []).append(new_constraint) + + api.edit_project(f"Add constraint '{cname}'", _apply) + api.set_selection({"kind": "constraint", "id": cid, "name": cname}) + + +def toggle_constraint( + api: StudioAPI, + _tree: QWidget, + element: dict[str, Any], +) -> None: + cid = str(element.get("id") or "") + + def _apply() -> None: + pdata = api.current_project.data if api.current_project else {} + for c in pdata.get("constraints", []): + if c.get("id") == cid: + c["enabled"] = not c.get("enabled", True) + break + + api.edit_project("Toggle constraint", _apply) diff --git a/src/setuav_studio/ui/project_explorer/operations.py b/src/setuav_studio/ui/panels/project_explorer/operations.py similarity index 97% rename from src/setuav_studio/ui/project_explorer/operations.py rename to src/setuav_studio/ui/panels/project_explorer/operations.py index 13e71806..c2671a58 100644 --- a/src/setuav_studio/ui/project_explorer/operations.py +++ b/src/setuav_studio/ui/panels/project_explorer/operations.py @@ -4,12 +4,12 @@ from PySide6.QtWidgets import QMessageBox, QTreeWidgetItem, QWidget -from setuav_studio.ui.project_explorer.style import ( +from setuav_studio.ui.panels.project_explorer.style import ( format_component_name, ) if TYPE_CHECKING: - from setuav_studio.ui.project_explorer.tree import ProjectExplorer + from setuav_studio.ui.panels.project_explorer.tree import ProjectExplorer from setuav_studio_sdk import ProjectTreeNodeContribution, StudioAPI @@ -444,35 +444,35 @@ def restore_item_text(self, item: QTreeWidgetItem, text: str) -> None: def add_parameter_action( self, is_constant: bool = False, parent: QWidget | None = None ) -> None: - from setuav_studio.ui.project_explorer.dialog_actions import ( + from setuav_studio.ui.panels.project_explorer.dialog_actions import ( add_parameter_action, ) add_parameter_action(self._api, self._tree, is_constant=is_constant, parent=parent) def add_constraint_action(self, parent: QWidget | None = None) -> None: - from setuav_studio.ui.project_explorer.dialog_actions import ( + from setuav_studio.ui.panels.project_explorer.dialog_actions import ( add_constraint_action, ) add_constraint_action(self._api, self._tree, parent=parent) def edit_parameter_fx(self, element: dict[str, Any], parent: QWidget | None = None) -> None: - from setuav_studio.ui.project_explorer.dialog_actions import ( + from setuav_studio.ui.panels.project_explorer.dialog_actions import ( edit_parameter_fx, ) edit_parameter_fx(self._api, self._tree, element, parent=parent) def edit_constraint_fx(self, element: dict[str, Any], parent: QWidget | None = None) -> None: - from setuav_studio.ui.project_explorer.dialog_actions import ( + from setuav_studio.ui.panels.project_explorer.dialog_actions import ( edit_constraint_fx, ) edit_constraint_fx(self._api, self._tree, element, parent=parent) def toggle_constraint(self, element: dict[str, Any]) -> None: - from setuav_studio.ui.project_explorer.dialog_actions import ( + from setuav_studio.ui.panels.project_explorer.dialog_actions import ( toggle_constraint, ) diff --git a/src/setuav_studio/ui/project_explorer/panel.py b/src/setuav_studio/ui/panels/project_explorer/panel.py similarity index 85% rename from src/setuav_studio/ui/project_explorer/panel.py rename to src/setuav_studio/ui/panels/project_explorer/panel.py index e672e303..25c2a997 100644 --- a/src/setuav_studio/ui/project_explorer/panel.py +++ b/src/setuav_studio/ui/panels/project_explorer/panel.py @@ -11,16 +11,15 @@ QWidget, ) -from setuav_studio.ui.configuration.bar import ConfigurationSelectorWidget from setuav_studio.ui.icons import get_icon -from setuav_studio.ui.project_explorer.tree import ProjectExplorer +from setuav_studio.ui.panels.project_explorer.tree import ProjectExplorer if TYPE_CHECKING: from setuav_studio_sdk import StudioAPI class ProjectExplorerPanel(QWidget): - """Panel containing search box, configuration selector, and the model tree.""" + """Panel containing search box and the model tree.""" def __init__(self, api: StudioAPI) -> None: super().__init__() @@ -28,11 +27,7 @@ def __init__(self, api: StudioAPI) -> None: layout.setContentsMargins(4, 4, 4, 4) layout.setSpacing(4) - # 1. Configuration selector - self.config_selector = ConfigurationSelectorWidget(api, self) - layout.addWidget(self.config_selector) - - # 2. Search bar + # Search bar search_box = QWidget() s_layout = QHBoxLayout(search_box) s_layout.setContentsMargins(2, 2, 2, 2) diff --git a/src/setuav_studio/ui/project_explorer/style.py b/src/setuav_studio/ui/panels/project_explorer/style.py similarity index 100% rename from src/setuav_studio/ui/project_explorer/style.py rename to src/setuav_studio/ui/panels/project_explorer/style.py diff --git a/src/setuav_studio/ui/project_explorer/tree.py b/src/setuav_studio/ui/panels/project_explorer/tree.py similarity index 93% rename from src/setuav_studio/ui/project_explorer/tree.py rename to src/setuav_studio/ui/panels/project_explorer/tree.py index 27de7414..1417daa3 100644 --- a/src/setuav_studio/ui/project_explorer/tree.py +++ b/src/setuav_studio/ui/panels/project_explorer/tree.py @@ -11,13 +11,13 @@ QTreeWidgetItem, ) -from setuav_studio.ui.project_explorer.context_menu import ( +from setuav_studio.ui.panels.project_explorer.context_menu import ( ProjectExplorerContextMenu, ) -from setuav_studio.ui.project_explorer.operations import ( +from setuav_studio.ui.panels.project_explorer.operations import ( ProjectExplorerOperations, ) -from setuav_studio.ui.project_explorer.style import ( +from setuav_studio.ui.panels.project_explorer.style import ( _ProjectExplorerBranchStyle, format_assembly_icon, format_assembly_type, @@ -25,7 +25,7 @@ format_component_type, get_geometry_icon_source, ) -from setuav_studio.ui.project_explorer.tree_builder import ( +from setuav_studio.ui.panels.project_explorer.tree_builder import ( ProjectTreeBuilder, ) @@ -78,7 +78,6 @@ def __init__(self, api: StudioAPI) -> None: self._saved_components: dict[str, dict[str, Any]] = {} self._saved_assemblies: dict[str, dict[str, Any]] = {} self._saved_analysis_results: dict[str, dict[str, Any]] = {} - self._last_active_config_id: str | None = None self._ops = ProjectExplorerOperations(self, api) self._builder = ProjectTreeBuilder(self, api) @@ -94,19 +93,12 @@ def __init__(self, api: StudioAPI) -> None: api.on_modified_changed(self._on_modified_changed) def set_project(self, project: ProjectDocument) -> None: - if hasattr(project, "get_configuration_manager"): - self._last_active_config_id = project.get_configuration_manager().get_active_id() self._capture_saved_state(project) self._rebuild_project(project) def refresh_project(self, project: ProjectDocument | None = None) -> None: current_project = project or self._api.current_project if current_project is not None: - if hasattr(current_project, "get_configuration_manager"): - curr_active_id = current_project.get_configuration_manager().get_active_id() - if curr_active_id != self._last_active_config_id: - self._last_active_config_id = curr_active_id - self._capture_saved_state(current_project) self._rebuild_project(current_project) def _rebuild_project(self, project: ProjectDocument) -> None: @@ -212,8 +204,6 @@ def _on_modified_changed(self, modified: bool) -> None: project = self._api.current_project if project is None: return - if hasattr(project, "get_configuration_manager"): - self._last_active_config_id = project.get_configuration_manager().get_active_id() self._capture_saved_state(project) self._refresh_modified_colors() diff --git a/src/setuav_studio/ui/project_explorer/tree_builder.py b/src/setuav_studio/ui/panels/project_explorer/tree_builder.py similarity index 99% rename from src/setuav_studio/ui/project_explorer/tree_builder.py rename to src/setuav_studio/ui/panels/project_explorer/tree_builder.py index 6afa6a15..04eb8ec1 100644 --- a/src/setuav_studio/ui/project_explorer/tree_builder.py +++ b/src/setuav_studio/ui/panels/project_explorer/tree_builder.py @@ -7,7 +7,7 @@ from PySide6.QtWidgets import QTreeWidgetItem from setuav_studio.ui.icons import get_icon -from setuav_studio.ui.project_explorer.style import ( +from setuav_studio.ui.panels.project_explorer.style import ( format_assembly_icon, format_assembly_type, format_component_name, @@ -17,7 +17,7 @@ if TYPE_CHECKING: from setuav_studio.project import ProjectDocument - from setuav_studio.ui.project_explorer.tree import ProjectExplorer + from setuav_studio.ui.panels.project_explorer.tree import ProjectExplorer from setuav_studio_sdk import ( ProjectTreeNodeContribution, StudioAPI, diff --git a/src/setuav_studio/ui/panels/properties/__init__.py b/src/setuav_studio/ui/panels/properties/__init__.py new file mode 100644 index 00000000..5104e7ff --- /dev/null +++ b/src/setuav_studio/ui/panels/properties/__init__.py @@ -0,0 +1,5 @@ +"""Properties inspector panel.""" + +from .properties_panel import PropertiesPanel + +__all__ = ["PropertiesPanel"] diff --git a/src/setuav_studio/ui/properties/properties_panel.py b/src/setuav_studio/ui/panels/properties/properties_panel.py similarity index 96% rename from src/setuav_studio/ui/properties/properties_panel.py rename to src/setuav_studio/ui/panels/properties/properties_panel.py index fbd3d569..dc9fb58c 100644 --- a/src/setuav_studio/ui/properties/properties_panel.py +++ b/src/setuav_studio/ui/panels/properties/properties_panel.py @@ -85,17 +85,13 @@ def set_selection(self, selection: Any | None) -> None: self._current_selection_key = new_key if kind == "parameter": - from setuav_studio.ui.parameter.editor import ( - ParameterPropertyEditor, - ) + from setuav_studio.ui.editor import ParameterPropertyEditor self._replace_widget(ParameterPropertyEditor(self._api, selection)) return if kind == "constraint": - from setuav_studio.ui.constraint.editor import ( - ConstraintPropertyEditor, - ) + from setuav_studio.ui.editor import ConstraintPropertyEditor self._replace_widget(ConstraintPropertyEditor(self._api, selection)) return diff --git a/src/setuav_studio/ui/parameter/__init__.py b/src/setuav_studio/ui/parameter/__init__.py deleted file mode 100644 index 832597d4..00000000 --- a/src/setuav_studio/ui/parameter/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Project parameters, variables, and expressions UI.""" - -from __future__ import annotations - -from setuav_studio.ui.parameter.editor import ParameterPropertyEditor -from setuav_studio.ui.parameter.expression_dialog import AdvancedExpressionDialog -from setuav_studio.ui.parameter.panel import ProjectParametersPanel -from setuav_studio.ui.parameter.parameters_dialog import AddParameterDialog - -__all__ = [ - "AddParameterDialog", - "AdvancedExpressionDialog", - "ParameterPropertyEditor", - "ProjectParametersPanel", -] diff --git a/src/setuav_studio/ui/parameter/panel.py b/src/setuav_studio/ui/parameter/panel.py deleted file mode 100644 index 1aa26b6c..00000000 --- a/src/setuav_studio/ui/parameter/panel.py +++ /dev/null @@ -1,384 +0,0 @@ -"""Panel for managing project-level constants, variables, and derived formulas.""" - -from __future__ import annotations - -import copy -from typing import TYPE_CHECKING, Any - -from PySide6.QtCore import Qt -from PySide6.QtGui import QColor -from PySide6.QtWidgets import ( - QAbstractItemView, - QHBoxLayout, - QHeaderView, - QMessageBox, - QPushButton, - QTableWidget, - QTableWidgetItem, - QVBoxLayout, - QWidget, -) - -from setuav_studio.model.configuration import ConfigurationManager -from setuav_studio.model.parameter import ParameterResolver -from setuav_studio.ui.icons import get_icon - -if TYPE_CHECKING: - from setuav_studio_sdk import StudioAPI - - -class ProjectParametersPanel(QWidget): - """Panel displaying and editing project parameters and formulas.""" - - def __init__(self, api: StudioAPI) -> None: - super().__init__() - self._api = api - self._resolver = ParameterResolver() - self._loading = False - - layout = QVBoxLayout(self) - layout.setContentsMargins(6, 6, 6, 6) - layout.setSpacing(4) - - # Toolbar / buttons - btn_bar = QHBoxLayout() - self.btn_add = QPushButton(get_icon("fa6s.plus"), "Add Parameter") - self.btn_add.clicked.connect(self._add_parameter) - btn_bar.addWidget(self.btn_add) - - self.btn_fx = QPushButton("fx Equation Assistant…") - self.btn_fx.clicked.connect(self._edit_with_fx_assistant) - btn_bar.addWidget(self.btn_fx) - - self.btn_remove = QPushButton("Remove") - self.btn_remove.clicked.connect(self._remove_parameter) - btn_bar.addWidget(self.btn_remove) - - btn_bar.addStretch() - layout.addLayout(btn_bar) - - # Parameters Table - self.table = QTableWidget(0, 3) - self.table.setHorizontalHeaderLabels(["Name", "Value / Formula", "Resolved"]) - self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive) - self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) - self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) - self.table.verticalHeader().setDefaultSectionSize(22) - self.table.verticalHeader().setMinimumSectionSize(20) - self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) - self.table.itemChanged.connect(self._on_item_changed) - self.table.doubleClicked.connect(self._on_table_double_clicked) - layout.addWidget(self.table) - - # Connect API listeners - api.on_project_changed(self._on_project_changed) - api.on_project_content_changed(self._on_project_content_changed) - - def _on_project_changed(self, project) -> None: - self._refresh() - - def _on_project_content_changed(self, project) -> None: - self._refresh() - - def _get_project_data(self) -> dict[str, Any] | None: - if self._api.current_project is None: - return None - return self._api.current_project.data - - def _get_active_cfg_manager(self, data: dict[str, Any]) -> ConfigurationManager: - proj = self._api.current_project - if proj is not None and hasattr(proj, "get_configuration_manager"): - getter = getattr(proj, "get_configuration_manager", None) - if callable(getter): - mgr = getter() - if isinstance(mgr, ConfigurationManager): - return mgr - return ConfigurationManager(data, self._resolver) - - def _format_resolved(self, res_val: Any, param_def: Any) -> str: - """Format resolved value with its unit symbol from the active unit system.""" - if not isinstance(res_val, (float, int)) or isinstance(res_val, bool): - return str(res_val) - - from setuav_studio.units import get_unit_manager - - um = get_unit_manager() - - # Determine quantity from parameter definition - quantity = "" - if isinstance(param_def, dict): - quantity = param_def.get("quantity") or "" - - if quantity: - display_val = um.to_display(float(res_val), quantity) - sym = um.get_unit_symbol(quantity) - formatted = f"{display_val:.4g}" - return f"{formatted} {sym}" if sym else formatted - - formatted = f"{res_val:.4g}" - return formatted - - def _refresh(self) -> None: - if self._loading: - return - self._loading = True - try: - data = self._get_project_data() - if data is None: - self.table.setRowCount(0) - self.btn_add.setEnabled(False) - self.btn_remove.setEnabled(False) - return - - self.btn_add.setEnabled(True) - self.btn_remove.setEnabled(True) - - raw_params: dict[str, Any] = data.setdefault("parameters", {}) - cfg_mgr = self._get_active_cfg_manager(data) - try: - resolved_params = cfg_mgr.get_effective_project_parameters() - except Exception: - resolved_params = {} - - self.table.setRowCount(len(raw_params)) - for row, (k, val) in enumerate(raw_params.items()): - # Col 0: Key - key_item = QTableWidgetItem(str(k)) - key_item.setData(Qt.ItemDataRole.UserRole, k) - - # Col 1: Formula / Raw Value - if isinstance(val, dict) and "value" in val: - val_str = str(val.get("value", "")) - else: - val_str = str(val) if val is not None else "" - val_item = QTableWidgetItem(val_str) - if self._resolver.evaluator.is_expression(val_str): - val_item.setToolTip("Formula expression") - elif isinstance(val, dict): - q_info = val.get("quantity") or val.get("unit") or "" - if q_info: - val_item.setToolTip(f"Quantity: {q_info}") - - # Col 2: Resolved Value (with unit) - res_val = resolved_params.get(k, "Error") - if res_val is None or res_val == "Error": - res_str = "#REF!" - res_item = QTableWidgetItem(res_str) - res_item.setForeground(QColor(229, 57, 53)) - res_item.setToolTip("Formula evaluation failed (#REF!)") - else: - res_str = self._format_resolved(res_val, val) - res_item = QTableWidgetItem(res_str) - res_item.setFlags(res_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - - # Check if overridden in active config - if cfg_mgr.is_overridden(f"project.parameters.{k}"): - res_item.setText(f"{res_str} ◆") - res_item.setToolTip("Overridden in active configuration") - - self.table.setItem(row, 0, key_item) - self.table.setItem(row, 1, val_item) - self.table.setItem(row, 2, res_item) - finally: - self._loading = False - - def _on_item_changed(self, item: QTableWidgetItem) -> None: - if self._loading or self._api.current_project is None: - return - - row = item.row() - data = self._get_project_data() - if not data: - return - - raw_params: dict[str, Any] = data.setdefault("parameters", {}) - key_item = self.table.item(row, 0) - val_item = self.table.item(row, 1) - - if not key_item or not val_item: - return - - old_key = key_item.data(Qt.ItemDataRole.UserRole) - new_key = key_item.text().strip() - val_text = val_item.text().strip() - - # Parse numeric value if possible and not expression - parsed_val: Any - if self._resolver.evaluator.is_expression(val_text): - parsed_val = val_text - else: - try: - parsed_val = float(val_text) if "." in val_text else int(val_text) - except ValueError: - parsed_val = val_text - - existing_val = raw_params.get(old_key or new_key) - if isinstance(existing_val, dict): - target_val: Any = copy.deepcopy(existing_val) - target_val["value"] = parsed_val - else: - target_val = parsed_val - - renamed_count = 0 - - def _apply() -> None: - nonlocal renamed_count - if old_key and old_key != new_key and old_key in raw_params: - del raw_params[old_key] - from setuav_studio.model.expression import rename_symbol_in_project - - renamed_count = rename_symbol_in_project(data, old_key, new_key) - if new_key: - raw_params[new_key] = target_val - - desc = ( - f"Rename parameter '{old_key}' to '{new_key}'" - if old_key and old_key != new_key - else f"Edit parameter '{new_key}'" - ) - self._api.edit_project(desc, _apply) - recompute = getattr(self._api.current_project, "recompute_expressions", None) - if recompute is not None: - recompute(self._api) - - if old_key and old_key != new_key: - msg = f'Renamed parameter "{old_key}" to "{new_key}"' - if renamed_count > 0: - msg += f" (updated {renamed_count} dependent expression{'s' if renamed_count > 1 else ''})" - self._api.show_status(msg, "success", 4000) - - self._refresh() - - def _add_parameter(self) -> None: - data = self._get_project_data() - if not data: - return - - from PySide6.QtWidgets import QDialog - - from setuav_studio.ui.parameter.parameters_dialog import AddParameterDialog - - raw_params: dict[str, Any] = data.setdefault("parameters", {}) - dlg = AddParameterDialog( - api=self._api, - existing_names=set(raw_params.keys()), - is_constant=False, - parent=self, - ) - if dlg.exec() == QDialog.DialogCode.Accepted: - param_name, param_val = dlg.get_data() - - def _apply() -> None: - pdata = self._api.current_project.data if self._api.current_project else {} - pdata.setdefault("parameters", {})[param_name] = param_val - - self._api.edit_project(f"Add parameter '{param_name}'", _apply) - self._refresh() - - def _remove_parameter(self) -> None: - data = self._get_project_data() - if not data: - return - selected_rows = self.table.selectionModel().selectedRows() - if not selected_rows: - return - row = selected_rows[0].row() - key_item = self.table.item(row, 0) - if not key_item: - return - param_name = key_item.text().strip() - - from setuav_studio.model.expression import find_symbol_usages_in_project - - usages = find_symbol_usages_in_project(data, param_name) - if usages: - usages_preview = "\n".join(f"• {loc}: {expr}" for loc, expr in usages[:5]) - if len(usages) > 5: - usages_preview += f"\n... and {len(usages) - 5} more" - reply = QMessageBox.warning( - self, - "Confirm Parameter Deletion", - f"Parameter '{param_name}' is referenced in {len(usages)} expression(s):\n\n" - f"{usages_preview}\n\n" - f"Deleting this parameter will break these expressions (#REF!).\n" - f"Are you sure you want to proceed?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - else: - reply = QMessageBox.question( - self, - "Remove Parameter", - f"Are you sure you want to remove parameter '{param_name}'?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - ) - - if reply == QMessageBox.StandardButton.Yes: - raw_params: dict[str, Any] = data.setdefault("parameters", {}) - - def _apply() -> None: - raw_params.pop(param_name, None) - - self._api.edit_project(f"Remove parameter '{param_name}'", _apply) - recompute = getattr(self._api.current_project, "recompute_expressions", None) - if recompute is not None: - recompute(self._api) - self._refresh() - - def _on_table_double_clicked(self, index) -> None: - if index.column() == 1: - self._edit_with_fx_assistant() - - def _edit_with_fx_assistant(self) -> None: - selected_rows = self.table.selectionModel().selectedRows() - if not selected_rows: - return - row = selected_rows[0].row() - key_item = self.table.item(row, 0) - val_item = self.table.item(row, 1) - if not key_item or not val_item: - return - - param_name = key_item.text().strip() - curr_val = val_item.text().strip() - - from setuav_studio.ui.parameter.expression_dialog import AdvancedExpressionDialog - - dlg = AdvancedExpressionDialog( - self._api, - initial_expression=curr_val, - title=f"Equation Assistant — {param_name}", - is_boolean_constraint=False, - parent=self, - ) - from PySide6.QtWidgets import QDialog - - if dlg.exec() == QDialog.DialogCode.Accepted: - new_expr = dlg.get_expression() - data = self._get_project_data() - if not data: - return - raw_params: dict[str, Any] = data.setdefault("parameters", {}) - - parsed: Any - if self._resolver.evaluator.is_expression(new_expr): - parsed = new_expr - else: - try: - parsed = float(new_expr) if "." in new_expr else int(new_expr) - except ValueError: - parsed = new_expr - - existing_val = raw_params.get(param_name) - - def _apply() -> None: - if isinstance(existing_val, dict): - target_val: Any = copy.deepcopy(existing_val) - target_val["value"] = parsed - else: - target_val = parsed - raw_params[param_name] = target_val - - self._api.edit_project(f"Edit parameter '{param_name}'", _apply) - self._refresh() diff --git a/src/setuav_studio/ui/parameter/parameters_dialog.py b/src/setuav_studio/ui/parameter/parameters_dialog.py deleted file mode 100644 index 52495e2e..00000000 --- a/src/setuav_studio/ui/parameter/parameters_dialog.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Dialog for adding/editing project parameters and constants with physical quantity selection.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QComboBox, - QDialog, - QDialogButtonBox, - QFormLayout, - QHBoxLayout, - QLabel, - QLineEdit, - QMessageBox, - QPushButton, - QVBoxLayout, - QWidget, -) - -from setuav_studio.ui.icons import get_icon -from setuav_studio.ui.parameter.expression_dialog import AdvancedExpressionDialog -from setuav_studio.units import get_quantity_choices, get_unit_manager - -if TYPE_CHECKING: - from setuav_studio_sdk import StudioAPI - - -import re - -PARAMETER_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$") - - -class AddParameterDialog(QDialog): - """Dialog to create a new project constant or parameter with a quantity type selector.""" - - def __init__( - self, - api: StudioAPI, - existing_names: set[str] | None = None, - is_constant: bool = False, - parent: QWidget | None = None, - ) -> None: - super().__init__(parent) - self._api = api - self._existing_names = existing_names or set() - self._is_constant = is_constant - - title = "Add Constant" if is_constant else "Add Parameter" - self.setWindowTitle(title) - self.setWindowIcon(get_icon("constant" if is_constant else "settings")) - self.setMinimumWidth(420) - - layout = QVBoxLayout(self) - layout.setSpacing(12) - - form = QFormLayout() - form.setLabelAlignment(Qt.AlignmentFlag.AlignRight) - - # 1. Parameter Name - self.name_edit = QLineEdit(self) - self.name_edit.setPlaceholderText("e.g. wingspan, payload_mass, cruise_speed") - form.addRow("Name:", self.name_edit) - - # 2. Value / Expression with fx button - val_container = QWidget(self) - val_layout = QHBoxLayout(val_container) - val_layout.setContentsMargins(0, 0, 0, 0) - val_layout.setSpacing(4) - - self.val_edit = QLineEdit(val_container) - self.val_edit.setPlaceholderText("0.0 or formula starting with =") - val_layout.addWidget(self.val_edit, 1) - - self.btn_fx = QPushButton("fx", val_container) - self.btn_fx.setFixedWidth(26) - self.btn_fx.setFixedHeight(22) - self.btn_fx.setStyleSheet("QPushButton { font-style: italic; font-weight: bold; }") - self.btn_fx.clicked.connect(self._open_fx_dialog) - val_layout.addWidget(self.btn_fx) - - form.addRow("Value / Formula:", val_container) - - # 3. Quantity Combo (Büyüklük Türü) - self.quantity_combo = QComboBox(self) - for q_id, q_label in get_quantity_choices(): - self.quantity_combo.addItem(q_label, q_id) - self.quantity_combo.currentIndexChanged.connect(self._on_quantity_changed) - form.addRow("Quantity (Büyüklük):", self.quantity_combo) - - # 4. Unit Info Label - self.unit_info_label = QLabel(self) - self.unit_info_label.setStyleSheet("color: #f0f0f0; font-weight: bold;") - form.addRow("Active Unit:", self.unit_info_label) - self._on_quantity_changed() - - # 5. Description (Optional) - self.desc_edit = QLineEdit(self) - self.desc_edit.setPlaceholderText("Optional description") - form.addRow("Description:", self.desc_edit) - - layout.addLayout(form) - - # Dialog buttons - self.button_box = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, self - ) - self.button_box.accepted.connect(self._on_accept) - self.button_box.rejected.connect(self.reject) - layout.addWidget(self.button_box) - - def _on_quantity_changed(self) -> None: - q_id = str(self.quantity_combo.currentData() or "") - if not q_id: - self.unit_info_label.setText("None (Dimensionless)") - else: - um = get_unit_manager() - sym = um.get_unit_symbol(q_id) - u_id = um.get_display_unit(q_id) - self.unit_info_label.setText(f"{u_id} ({sym})") - - def _open_fx_dialog(self) -> None: - dlg = AdvancedExpressionDialog( - self._api, - initial_expression=self.val_edit.text().strip(), - title="Formula Assistant", - is_boolean_constraint=False, - parent=self, - ) - if dlg.exec() == QDialog.DialogCode.Accepted: - self.val_edit.setText(dlg.get_expression()) - - def _on_accept(self) -> None: - name = self.name_edit.text().strip() - if not name: - QMessageBox.warning(self, "Invalid Name", "Parameter name cannot be empty.") - return - if not PARAMETER_NAME_PATTERN.match(name): - QMessageBox.warning( - self, - "Invalid Name", - "Parameter name must start with a letter or underscore and contain only " - "letters, digits, underscores, or hyphens (e.g. 'wing_span', 'v_cruise').", - ) - return - if name in self._existing_names: - QMessageBox.warning(self, "Duplicate Name", f"Parameter '{name}' already exists.") - return - - self.accept() - - def get_data(self) -> tuple[str, Any]: - """Return (param_name, param_value_or_dict).""" - name = self.name_edit.text().strip() - val_str = self.val_edit.text().strip() or "0.0" - q_id = str(self.quantity_combo.currentData() or "") - - parsed: Any - if val_str.startswith("=") or not val_str.replace(".", "", 1).replace("-", "", 1).isdigit(): - parsed = val_str - else: - try: - parsed = float(val_str) if "." in val_str else int(val_str) - except ValueError: - parsed = val_str - - desc = self.desc_edit.text().strip() - if q_id or desc: - um = get_unit_manager() - stored_val = parsed - if q_id and isinstance(parsed, (int, float)): - stored_val = um.to_base(float(parsed), q_id) - res_dict: dict[str, Any] = {"value": stored_val} - if q_id: - res_dict["quantity"] = q_id - res_dict["unit"] = um.get_unit_symbol(q_id) - if desc: - res_dict["description"] = desc - return name, res_dict - return name, parsed diff --git a/src/setuav_studio/ui/project_explorer/__init__.py b/src/setuav_studio/ui/project_explorer/__init__.py deleted file mode 100644 index 5e559424..00000000 --- a/src/setuav_studio/ui/project_explorer/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Project explorer tree and panel widgets.""" - -from setuav_studio.ui.project_explorer.panel import ( - ProjectExplorerPanel, -) -from setuav_studio.ui.project_explorer.tree import ProjectExplorer - -__all__ = ["ProjectExplorer", "ProjectExplorerPanel"] diff --git a/src/setuav_studio/ui/project_explorer/dialog_actions.py b/src/setuav_studio/ui/project_explorer/dialog_actions.py deleted file mode 100644 index 3e8959e1..00000000 --- a/src/setuav_studio/ui/project_explorer/dialog_actions.py +++ /dev/null @@ -1,130 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -from PySide6.QtWidgets import QDialog, QWidget - -if TYPE_CHECKING: - from setuav_studio_sdk import StudioAPI - - -def add_parameter_action( - api: StudioAPI, - tree: QWidget, - is_constant: bool = False, - parent: QWidget | None = None, -) -> None: - from setuav_studio.ui.parameter.parameters_dialog import AddParameterDialog - - data = api.current_project.data if api.current_project else {} - raw = data.setdefault("parameters", {}) - dlg = AddParameterDialog( - api=api, - existing_names=set(raw.keys()), - is_constant=is_constant, - parent=parent or tree, - ) - if dlg.exec() == QDialog.DialogCode.Accepted: - param_name, param_val = dlg.get_data() - - def _apply() -> None: - pdata = api.current_project.data if api.current_project else {} - pdata.setdefault("parameters", {})[param_name] = param_val - - action_name = "constant" if is_constant else "parameter" - api.edit_project(f"Add {action_name} '{param_name}'", _apply) - - -def add_constraint_action( - api: StudioAPI, - tree: QWidget, - parent: QWidget | None = None, -) -> None: - from setuav_studio.ui.constraint.constraints_dialog import ConstraintEditDialog - - dlg = ConstraintEditDialog( - parent or tree, - api=api, - project_data=api.current_project.data if api.current_project else {}, - ) - if dlg.exec() == QDialog.DialogCode.Accepted: - data = dlg.get_data() - - def _apply() -> None: - pdata = api.current_project.data if api.current_project else {} - pdata.setdefault("constraints", []).append(data) - - api.edit_project(f"Add constraint '{data['name']}'", _apply) - - -def edit_parameter_fx( - api: StudioAPI, - tree: QWidget, - element: dict[str, Any], - parent: QWidget | None = None, -) -> None: - from setuav_studio.ui.parameter.expression_dialog import AdvancedExpressionDialog - - param_key = str(element.get("key") or "") - val = str(element.get("value") or "") - dlg = AdvancedExpressionDialog( - api, - initial_expression=val, - title=f"Equation Assistant — {param_key}", - is_boolean_constraint=False, - parent=parent or tree, - ) - if dlg.exec() == QDialog.DialogCode.Accepted: - new_expr = dlg.get_expression() - - def _apply() -> None: - pdata = api.current_project.data if api.current_project else {} - pdata.setdefault("parameters", {})[param_key] = new_expr - - api.edit_project(f"Edit parameter '{param_key}'", _apply) - - -def edit_constraint_fx( - api: StudioAPI, - tree: QWidget, - element: dict[str, Any], - parent: QWidget | None = None, -) -> None: - from setuav_studio.ui.constraints.constraints_dialog import ConstraintEditDialog - - cid = str(element.get("id") or "") - dlg = ConstraintEditDialog( - parent or tree, - initial_data=element, - api=api, - project_data=api.current_project.data if api.current_project else {}, - ) - if dlg.exec() == QDialog.DialogCode.Accepted: - updated = dlg.get_data() - - def _apply() -> None: - pdata = api.current_project.data if api.current_project else {} - constraints = pdata.get("constraints", []) - for i, c in enumerate(constraints): - if c.get("id") == cid: - constraints[i] = updated - break - - api.edit_project(f"Edit constraint '{updated['name']}'", _apply) - - -def toggle_constraint( - api: StudioAPI, - _tree: QWidget, - element: dict[str, Any], -) -> None: - cid = str(element.get("id") or "") - - def _apply() -> None: - pdata = api.current_project.data if api.current_project else {} - for c in pdata.get("constraints", []): - if c.get("id") == cid: - c["enabled"] = not c.get("enabled", True) - break - - api.edit_project("Toggle constraint", _apply) diff --git a/src/setuav_studio/ui/properties/__init__.py b/src/setuav_studio/ui/properties/__init__.py deleted file mode 100644 index a168043d..00000000 --- a/src/setuav_studio/ui/properties/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Properties inspector panel.""" - -from setuav_studio.ui.properties.properties_panel import PropertiesPanel - -__all__ = ["PropertiesPanel"] diff --git a/src/setuav_studio/ui/shell/actions.py b/src/setuav_studio/ui/shell/actions.py index ada863f6..04139a41 100644 --- a/src/setuav_studio/ui/shell/actions.py +++ b/src/setuav_studio/ui/shell/actions.py @@ -382,10 +382,9 @@ def open_task_monitor(self) -> None: self._window._status_bar_manager.open_task_monitor_window() def open_constraints(self) -> None: - from setuav_studio.ui.constraint.constraints_dialog import ManageConstraintsDialog + from setuav_studio.ui.panels.project_explorer.dialog_actions import add_constraint_action - dlg = ManageConstraintsDialog(self._api, parent=self._window) - dlg.exec() + add_constraint_action(self._api, self._window) def bind_plugin_manager(self, manager: PluginManager) -> None: self.plugin_manager = manager diff --git a/src/setuav_studio/ui/shell/native_registrations.py b/src/setuav_studio/ui/shell/native_registrations.py index 6e04f760..ff1bf1c9 100644 --- a/src/setuav_studio/ui/shell/native_registrations.py +++ b/src/setuav_studio/ui/shell/native_registrations.py @@ -6,13 +6,11 @@ from setuav_studio.ui.editor import ( EnvelopeEditor, - InstanceEditor, MassPropertiesEditor, TransformEditor, ) -from setuav_studio.ui.parameter import ProjectParametersPanel -from setuav_studio.ui.project_explorer import ProjectExplorerPanel -from setuav_studio.ui.properties import PropertiesPanel +from setuav_studio.ui.panels.project_explorer import ProjectExplorerPanel +from setuav_studio.ui.panels.properties import PropertiesPanel from setuav_studio_sdk import ( ComponentTreeNodeContribution, PanelContribution, @@ -106,15 +104,6 @@ def register_native_contributions(api: StudioAPI) -> None: icon="fa6s.sliders", ) ) - api.add_panel( - PanelContribution( - id="core:parameters", - title="Project Parameters", - factory=lambda: ProjectParametersPanel(api), - area=Qt.DockWidgetArea.BottomDockWidgetArea, - icon="constant", - ) - ) # 2. Native Tree Providers api.register_component_tree_provider( @@ -127,10 +116,6 @@ def register_native_contributions(api: StudioAPI) -> None: ) # 3. Native Kind Editors - api.register_kind_editor( - "instance", - lambda instance: InstanceEditor(api, instance), - ) api.register_kind_editor( "transform", lambda selection: TransformEditor(api, selection), diff --git a/src/setuav_studio/ui/shell/status_bar.py b/src/setuav_studio/ui/shell/status_bar.py index 08d9a45c..636613a7 100644 --- a/src/setuav_studio/ui/shell/status_bar.py +++ b/src/setuav_studio/ui/shell/status_bar.py @@ -13,8 +13,8 @@ QToolButton, ) +from setuav_studio.ui.dialog.log import install_log_buffer from setuav_studio.ui.icons import get_icon -from setuav_studio.ui.log.buffer import install_log_buffer from setuav_studio.ui.theme import status_color if TYPE_CHECKING: @@ -181,7 +181,7 @@ def refresh_status_color(self) -> None: def open_log_window(self) -> None: if self._log_window is None: - from setuav_studio.ui.log.window import LogWindow + from setuav_studio.ui.dialog.log import LogWindow self._log_window = LogWindow(self._window) self._log_window.show() @@ -190,7 +190,7 @@ def open_log_window(self) -> None: def open_task_monitor_window(self) -> None: if self._task_monitor_window is None: - from setuav_studio.ui.task_monitor import TaskMonitorDialog + from setuav_studio.ui.dialog.task_monitor import TaskMonitorDialog self._task_monitor_window = TaskMonitorDialog(self._api, self._window) self._task_monitor_window.show() diff --git a/src/setuav_studio/ui/shell/toolbar_manager.py b/src/setuav_studio/ui/shell/toolbar_manager.py index dfdc5234..2f6dbc27 100644 --- a/src/setuav_studio/ui/shell/toolbar_manager.py +++ b/src/setuav_studio/ui/shell/toolbar_manager.py @@ -22,7 +22,7 @@ class ToolbarManager: - """Manages workspace switcher, configuration bar, standard and dynamic toolbars.""" + """Manages workspace switcher, standard and dynamic toolbars.""" def __init__(self, window: QMainWindow, api: StudioAPI) -> None: self._window = window diff --git a/src/setuav_studio/ui/widget/table.py b/src/setuav_studio/ui/widget/table.py index 939f5d22..44e482f3 100644 --- a/src/setuav_studio/ui/widget/table.py +++ b/src/setuav_studio/ui/widget/table.py @@ -383,7 +383,7 @@ def _handle_button_clicked(self) -> None: if self._on_open_assistant: self._on_open_assistant(self._raw_expression) elif self._api is not None: - from setuav_studio.ui.parameter.expression_dialog import AdvancedExpressionDialog + from setuav_studio.ui.dialog.expression_dialog import AdvancedExpressionDialog curr_text = self._raw_expression.strip() dlg = AdvancedExpressionDialog( diff --git a/tests/aerodynamics/test_aero_plugin.py b/tests/aerodynamics/test_aero_plugin.py index bbd4f62f..fa608a84 100644 --- a/tests/aerodynamics/test_aero_plugin.py +++ b/tests/aerodynamics/test_aero_plugin.py @@ -43,7 +43,7 @@ WorkspaceContribution, ) from setuav_studio.project import ProjectDocument -from setuav_studio.ui.project_explorer import ProjectExplorer +from setuav_studio.ui.panels.project_explorer import ProjectExplorer from tests._common import get_qapp @@ -813,7 +813,7 @@ def addAction(self, *args, **kwargs): def exec(self, *args, **kwargs): return None - with patch("setuav_studio.ui.project_explorer.context_menu.QMenu", MockMenu): + with patch("setuav_studio.ui.panels.project_explorer.context_menu.QMenu", MockMenu): explorer._open_context_menu(explorer.visualItemRect(result_item).center()) self.assertIn("Rename", menu_actions) diff --git a/tests/core/test_component_editor.py b/tests/core/test_component_editor.py index a187669e..eeecb1f5 100644 --- a/tests/core/test_component_editor.py +++ b/tests/core/test_component_editor.py @@ -116,8 +116,8 @@ def mutate() -> None: def test_properties_panel_refreshes_on_project_content_change(self) -> None: import copy - from setuav_studio.ui.editor import InstanceEditor - from setuav_studio.ui.properties import PropertiesPanel + from setuav_studio.ui.editor import BaseComponentEditor + from setuav_studio.ui.panels.properties import PropertiesPanel api = StudioAPI() doc = ProjectDocument( @@ -135,14 +135,14 @@ def test_properties_panel_refreshes_on_project_content_change(self) -> None: }, ) api._host.set_project(doc) - api.register_component_editor("test:comp", lambda c: InstanceEditor(api, c)) + api.register_component_editor("test:comp", lambda c: BaseComponentEditor(api, c)) panel = PropertiesPanel(api) self.addCleanup(panel.deleteLater) api.set_selection(doc.get_component("wing-1")) self.assertIsNotNone(panel._current_widget) - self.assertEqual(panel._current_widget._instance["parameters"]["span"], 1.5) + self.assertEqual(panel._current_widget._component["parameters"]["span"], 1.5) # Deepcopy project components simulating config switch or edit_project doc.data["components"] = copy.deepcopy(doc.data["components"]) @@ -151,7 +151,7 @@ def test_properties_panel_refreshes_on_project_content_change(self) -> None: # Panel must have refreshed and point to the live component self.assertIsNotNone(panel._current_widget) - self.assertEqual(panel._current_widget._instance["parameters"]["span"], 4.0) + self.assertEqual(panel._current_widget._component["parameters"]["span"], 4.0) if __name__ == "__main__": diff --git a/tests/core/test_configuration_ui.py b/tests/core/test_configuration_ui.py deleted file mode 100644 index d83699ae..00000000 --- a/tests/core/test_configuration_ui.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Unit tests for ConfigurationSelectorWidget and configuration dialogs.""" - -from __future__ import annotations - -import unittest -from pathlib import Path - -from setuav_studio.api import StudioAPI -from setuav_studio.project import ProjectDocument -from setuav_studio.ui.configuration.bar import ConfigurationSelectorWidget -from setuav_studio.ui.configuration.dialog import ConfigurationEditDialog -from setuav_studio.ui.project_explorer.panel import ProjectExplorerPanel -from tests._common import get_qapp - - -class TestConfigurationUI(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.app = get_qapp() - - def test_configuration_selector_sync(self) -> None: - api = StudioAPI() - doc = ProjectDocument( - path=Path("/tmp/test.json"), - kind="json", - data={ - "configurations": [ - { - "id": "cruise", - "name": "Cruise", - "tag": "CRZ", - "parameter_overrides": {}, - "is_default": True, - }, - { - "id": "vtol", - "name": "VTOL", - "tag": "VTOL", - "parameter_overrides": {}, - }, - ] - }, - ) - api._host.set_project(doc) - - selector = ConfigurationSelectorWidget(api) - self.assertIsNotNone(selector.manager) - - # Dropdown should have Base + 2 configs + separator + 1 action = 5 items - combo = selector.config_combo - self.assertTrue(combo.count() >= 3) - self.assertEqual(combo.itemText(0), "[Base Configuration]") - self.assertEqual(combo.itemText(1), "[CRZ] Cruise") - self.assertEqual(combo.itemText(2), "[VTOL] VTOL") - - # Base configuration is initially selected (index 0) - self.assertEqual(combo.currentIndex(), 0) - - # Switch to Cruise via combo - combo.setCurrentIndex(1) - combo.activated.emit(1) - self.assertEqual(selector.manager.get_active_id(), "cruise") - - def test_project_explorer_contains_configuration_selector(self) -> None: - api = StudioAPI() - panel = ProjectExplorerPanel(api) - self.assertIsNotNone(panel.config_selector) - self.assertIsInstance(panel.config_selector, ConfigurationSelectorWidget) - - def test_configuration_edit_dialog_validation(self) -> None: - dlg = ConfigurationEditDialog(None, {"name": "High Speed", "tag": "SPD"}) - data = dlg.get_data() - self.assertEqual(data["name"], "High Speed") - self.assertEqual(data["tag"], "SPD") - self.assertNotIn("color", data) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/core/test_constraint.py b/tests/core/test_constraint.py index a85343f9..5720585a 100644 --- a/tests/core/test_constraint.py +++ b/tests/core/test_constraint.py @@ -3,14 +3,8 @@ from __future__ import annotations import unittest -from pathlib import Path -from setuav_studio.api import StudioAPI from setuav_studio.model.constraint import ConstraintChecker -from setuav_studio.project import ProjectDocument -from setuav_studio.ui.constraint.constraints_dialog import ConstraintEditDialog -from setuav_studio.ui.constraint.status import ConstraintStatusWidget -from tests._common import get_qapp class ConstraintEngineTests(unittest.TestCase): @@ -93,44 +87,5 @@ def test_syntax_or_unknown_variable_error(self) -> None: self.assertIsNotNone(res.error) -class ConstraintUITests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.app = get_qapp() - - def test_status_widget_reflects_project_state(self) -> None: - api = StudioAPI() - doc = ProjectDocument( - path=Path("/tmp/test.json"), - kind="json", - data={ - "parameters": {"mtow": 20.0, "wing_area": 1.0}, - "constraints": [ - { - "id": "c1", - "name": "Wing Loading", - "expression": "mtow / wing_area <= 25", - "enabled": True, - } - ], - }, - ) - api._host.set_project(doc) - - widget = ConstraintStatusWidget(api) - self.assertIn("Constraints OK", widget.btn.text()) - - # Cause violation - doc.data["parameters"]["mtow"] = 50.0 - widget.refresh() - self.assertIn("1 Violation", widget.btn.text()) - - def test_edit_dialog_validation(self) -> None: - dlg = ConstraintEditDialog(None, {"name": "Span Check", "expression": "span > 1000"}) - data = dlg.get_data() - self.assertEqual(data["name"], "Span Check") - self.assertEqual(data["expression"], "span > 1000") - - if __name__ == "__main__": unittest.main() diff --git a/tests/core/test_instance.py b/tests/core/test_instance.py deleted file mode 100644 index 51d262ed..00000000 --- a/tests/core/test_instance.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Focused tests for the core component-instance editor.""" - -from __future__ import annotations - -import unittest -from pathlib import Path -from typing import Any - -from PySide6.QtCore import QSignalBlocker -from PySide6.QtWidgets import QComboBox - -from setuav_studio.api import StudioAPI -from setuav_studio.project import ProjectDocument -from setuav_studio.ui.editor.instance import InstanceEditor -from tests._common import get_qapp - - -class InstanceEditorTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.app = get_qapp() - - def setUp(self) -> None: - self.source = { - "id": "wing-left", - "kind": "component", - "name": "Left Wing", - } - self.parent = { - "id": "fuselage", - "kind": "component", - "name": "Fuselage", - } - self.instance: dict[str, Any] = { - "id": "wing-right", - "kind": "instance", - "name": "Right Wing", - "source": "wing-left", - "attach_to": "fuselage", - "derivation": {"type": "mirror", "plane": "YZ", "offset": 12.5}, - "transform": { - "position": {"x": 10, "y": 20, "z": 30}, - "rotation": {"roll": 1, "pitch": 2, "yaw": 3}, - }, - } - self.api = StudioAPI() - self.project = ProjectDocument( - path=Path("instance-test.json"), - kind="json", - data={"components": [self.source, self.parent, self.instance]}, - ) - self.api._host.set_project(self.project) - self.editor = InstanceEditor(self.api, self.instance) - self.addCleanup(self.editor.deleteLater) - - def _row(self, key: str) -> int: - for row in range(self.editor.properties_table.rowCount()): - if self.editor._key(row) == key: - return row - self.fail(f"Property row not found: {key}") - - def _combo(self, key: str) -> QComboBox: - widget = self.editor.properties_table.cellWidget(self._row(key), 1) - self.assertIsInstance(widget, QComboBox) - return widget - - def test_renders_mirror_properties_and_transform(self) -> None: - table = self.editor.properties_table - - self.assertEqual(table.rowCount(), 7) - self.assertEqual(table.item(self._row("source"), 1).text(), "Left Wing") - self.assertEqual(table.item(self._row("parent"), 1).text(), "Fuselage") - self.assertEqual(self._combo("derivation_type").currentData(), "mirror") - self.assertEqual(self._combo("plane").currentData(), "YZ") - self.assertEqual(table.item(self._row("offset"), 1).text(), "12.5") - self.assertEqual( - [self.editor.transform_table.item(0, column).text() for column in range(3)], - ["10", "20", "30"], - ) - self.assertEqual( - [self.editor.transform_table.item(1, column).text() for column in range(3)], - ["1", "2", "3"], - ) - - def test_copy_defaults_and_missing_component_names_are_safe(self) -> None: - copy_instance: dict[str, Any] = { - "id": "copy", - "kind": "instance", - "source": "missing", - "parent": "also-missing", - } - editor = InstanceEditor(self.api, copy_instance) - self.addCleanup(editor.deleteLater) - - self.assertEqual(editor.properties_table.rowCount(), 5) - self.assertEqual(editor._property_value("source", {}), "missing") - self.assertEqual(editor._property_value("parent", {}), "also-missing") - self.assertEqual(editor._property_value("derivation_type", {}), "copy") - self.assertEqual(editor._property_value("plane", {}), "XZ") - self.assertEqual(editor._property_value("offset", {}), "0") - self.assertEqual(editor._property_value("name", {}), "") - self.assertEqual(editor.transform_table.item(0, 0).text(), "0") - - copy_instance.pop("parent") - self.assertEqual(editor._property_value("parent", {}), "—") - - def test_name_edit_is_undoable_and_blank_name_is_rejected(self) -> None: - name_item = self.editor.properties_table.item(self._row("name"), 1) - name_item.setText("Renamed Wing") - - self.assertEqual(self.instance["name"], "Renamed Wing") - self.assertEqual(self.api._host.undo_stack.undoText(), "Rename component instance") - self.api.undo() - self.assertEqual(self.instance["name"], "Right Wing") - self.api.redo() - self.assertEqual(self.instance["name"], "Renamed Wing") - - command_count = self.api._host.undo_stack.count() - self.editor.properties_table.item(self._row("name"), 1).setText(" ") - self.assertEqual(self.instance["name"], "Renamed Wing") - self.assertEqual(self.api._host.undo_stack.count(), command_count) - self.assertEqual( - self.editor.properties_table.item(self._row("name"), 1).text(), - "Renamed Wing", - ) - - self.editor._update_property(self._row("name"), 0) - self.editor._loading = True - self.editor._update_property(self._row("name"), 1) - self.editor._loading = False - - def test_mirror_offset_accepts_numbers_and_rejects_invalid_text(self) -> None: - offset_item = self.editor.properties_table.item(self._row("offset"), 1) - offset_item.setText("25.75") - - self.assertEqual(self.instance["derivation"]["offset"], 25.75) - self.assertEqual(self.api._host.undo_stack.undoText(), "Edit mirror offset") - - command_count = self.api._host.undo_stack.count() - self.editor.properties_table.item(self._row("offset"), 1).setText("invalid") - self.assertEqual(self.instance["derivation"]["offset"], 25.75) - self.assertEqual(self.api._host.undo_stack.count(), command_count) - - def test_derivation_and_plane_combos_update_the_instance(self) -> None: - derivation_combo = self._combo("derivation_type") - derivation_combo.setCurrentIndex(derivation_combo.findData("copy")) - - self.assertEqual(self.instance["derivation"], {"type": "copy"}) - self.assertEqual(self.editor.properties_table.rowCount(), 5) - - self.editor._change_derivation("mirror") - self.assertEqual( - self.instance["derivation"], - {"type": "mirror", "plane": "XZ", "offset": 0.0}, - ) - plane_combo = self._combo("plane") - plane_combo.setCurrentIndex(plane_combo.findData("XY")) - self.assertEqual(self.instance["derivation"]["plane"], "XY") - - command_count = self.api._host.undo_stack.count() - self.editor._change_derivation("invalid") - self.editor._change_plane("invalid") - self.editor._loading = True - self.editor._change_derivation("copy") - self.editor._change_plane("YZ") - self.editor._loading = False - self.assertEqual(self.api._host.undo_stack.count(), command_count) - - def test_transform_edit_is_atomic_and_invalid_values_are_rejected(self) -> None: - values = ((100.5, -20.0, 3.0), (4.0, 5.5, -6.0)) - self.editor._loading = True - for row, row_values in enumerate(values): - for column, value in enumerate(row_values): - self.editor.transform_table.item(row, column).setText(str(value)) - self.editor._loading = False - self.editor._update_transform(0, 0) - - self.assertEqual( - self.instance["transform"], - { - "position": {"x": 100.5, "y": -20.0, "z": 3.0}, - "rotation": {"roll": 4.0, "pitch": 5.5, "yaw": -6.0}, - }, - ) - self.assertEqual(self.api._host.undo_stack.undoText(), "Edit instance transform") - - command_count = self.api._host.undo_stack.count() - with QSignalBlocker(self.editor.transform_table): - self.editor.transform_table.item(0, 0).setText("not-a-number") - self.editor._update_transform(0, 0) - self.assertEqual(self.api._host.undo_stack.count(), command_count) - self.assertEqual(self.editor.transform_table.item(0, 0).text(), "100.5") - - with QSignalBlocker(self.editor.transform_table): - self.editor.transform_table.takeItem(1, 2) - self.editor._update_transform(1, 2) - self.assertEqual(self.api._host.undo_stack.count(), command_count) - - def test_component_lookup_and_mapping_helpers_handle_malformed_data(self) -> None: - self.project.data["components"] = ["invalid", {"id": "unnamed"}] - self.assertEqual(self.editor._component_name("unnamed"), "unnamed") - self.assertEqual(self.editor._component_name("unknown"), "unknown") - - self.project.data["components"] = {"id": "not-a-list"} - self.assertEqual(self.editor._component_name("unknown"), "unknown") - self.api.current_project = None - self.assertEqual(self.editor._component_name(None), "") - - owner: dict[str, Any] = {"nested": "invalid"} - self.assertEqual(InstanceEditor._mapping(owner, "nested"), {}) - nested = InstanceEditor._object(owner, "nested") - self.assertEqual(nested, {}) - self.assertIs(owner["nested"], nested) - self.assertIs(InstanceEditor._object(owner, "nested"), nested) - - def test_widget_helpers_tolerate_missing_rows_and_optional_icons(self) -> None: - header = InstanceEditor._header("Plain header") - self.addCleanup(header.deleteLater) - - self.assertEqual(self.editor._key(-1), "") - self.editor._update_property(self._row("id"), 1) - self.editor._set_combo("missing", "x", [("x", "X")], lambda _value: None) - - source_row = self._row("source") - self.editor.properties_table.takeItem(source_row, 1) - self.editor._set_combo("source", "x", [("x", "X")], lambda _value: None) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/core/test_parameters_panel.py b/tests/core/test_parameters_panel.py deleted file mode 100644 index 67d4cdcc..00000000 --- a/tests/core/test_parameters_panel.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Unit tests for ProjectParametersPanel.""" - -from __future__ import annotations - -import unittest -from pathlib import Path - -from setuav_studio.api import StudioAPI -from setuav_studio.project import ProjectDocument -from setuav_studio.ui.parameter.panel import ProjectParametersPanel -from tests._common import get_qapp - - -class TestProjectParametersPanel(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.app = get_qapp() - - def test_parameters_panel_loading_and_editing(self) -> None: - api = StudioAPI() - doc = ProjectDocument( - path=Path("/tmp/test.json"), - kind="json", - data={ - "parameters": { - "aspect_ratio": 8.0, - "wing_area": 2.0, - "wing_span": "= sqrt(aspect_ratio * wing_area)", - } - }, - ) - api._host.set_project(doc) - - panel = ProjectParametersPanel(api) - - # Should load 3 parameters - self.assertEqual(panel.table.rowCount(), 3) - - # Check values - names = [panel.table.item(r, 0).text() for r in range(panel.table.rowCount())] - self.assertIn("aspect_ratio", names) - self.assertIn("wing_area", names) - self.assertIn("wing_span", names) - - span_row = names.index("wing_span") - self.assertEqual(panel.table.item(span_row, 1).text(), "= sqrt(aspect_ratio * wing_area)") - self.assertEqual(panel.table.item(span_row, 2).text(), "4") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/core/test_plugin_installer.py b/tests/core/test_plugin_installer.py index d882dee3..688eee0d 100644 --- a/tests/core/test_plugin_installer.py +++ b/tests/core/test_plugin_installer.py @@ -22,6 +22,9 @@ def setUpClass(cls) -> None: cls._app = QApplication.instance() or QApplication([]) def setUp(self) -> None: + from PySide6.QtCore import QSettings + + QSettings().setValue("plugins/disabled", []) self.temp_dir = tempfile.TemporaryDirectory() self.user_dir = Path(self.temp_dir.name) / "plugins" self.user_dir.mkdir(parents=True, exist_ok=True) diff --git a/tests/core/test_plugins.py b/tests/core/test_plugins.py index 095080f9..f59e3518 100644 --- a/tests/core/test_plugins.py +++ b/tests/core/test_plugins.py @@ -21,7 +21,7 @@ from setuav_studio.ui.editor.envelope import EnvelopeEditor from setuav_studio.ui.editor.mass import MassPropertiesEditor from setuav_studio.ui.editor.transform import TransformEditor -from setuav_studio.ui.project_explorer import ProjectExplorer +from setuav_studio.ui.panels.project_explorer import ProjectExplorer from setuav_studio.ui.shell.native_registrations import register_native_contributions from tests._common import get_qapp @@ -100,7 +100,7 @@ def test_native_contributions_registers_panels(self) -> None: self.assertEqual( [panel.id for panel in self.panels], - ["core:project-explorer", "core:properties", "core:parameters"], + ["core:project-explorer", "core:properties"], ) def test_core_plugin_contributes_transform_tree_node_and_editor(self) -> None: diff --git a/tests/core/test_shell_project_lifecycle.py b/tests/core/test_shell_project_lifecycle.py index f7f4959b..2fca4ebe 100644 --- a/tests/core/test_shell_project_lifecycle.py +++ b/tests/core/test_shell_project_lifecycle.py @@ -195,7 +195,7 @@ def test_save_project_handles_empty_error_and_success_states(self) -> None: self.assertEqual(self.window._status_label.text(), "Project saved") def test_save_project_does_not_resurrect_deselected_selection(self) -> None: - from setuav_studio.ui.project_explorer.tree import ProjectExplorer + from setuav_studio.ui.panels.project_explorer.tree import ProjectExplorer project = self._project() project.data["components"] = [ diff --git a/tests/core/test_tasks.py b/tests/core/test_tasks.py index 13b6673b..7aa66612 100644 --- a/tests/core/test_tasks.py +++ b/tests/core/test_tasks.py @@ -15,7 +15,7 @@ TaskPriority, TaskStatus, ) -from setuav_studio.ui.task_monitor import TaskMonitorDialog +from setuav_studio.ui.dialog.task_monitor import TaskMonitorDialog from setuav_studio_sdk.events import StudioEvents from tests._common import get_qapp diff --git a/tests/core/test_workspaces.py b/tests/core/test_workspaces.py index bc4fbded..5561dc81 100644 --- a/tests/core/test_workspaces.py +++ b/tests/core/test_workspaces.py @@ -149,7 +149,7 @@ def test_tool_registration_in_tools_menu(self) -> None: def test_log_button_opens_log_window(self) -> None: import logging - from setuav_studio.ui.log.buffer import clear_log_buffer, install_log_buffer + from setuav_studio.ui.dialog.log import clear_log_buffer, install_log_buffer clear_log_buffer() install_log_buffer() diff --git a/tests/electrical_propulsion/test_electrical_propulsion.py b/tests/electrical_propulsion/test_electrical_propulsion.py index 92d566c5..3e5941da 100644 --- a/tests/electrical_propulsion/test_electrical_propulsion.py +++ b/tests/electrical_propulsion/test_electrical_propulsion.py @@ -37,6 +37,8 @@ def test_plugin_discovery_and_registration(self) -> None: self.assertIn("org.setuav.core:esc", api._component_editors) self.assertIn("org.setuav.core:battery", api._component_editors) self.assertIn("org.setuav.core:electric-propulsion-system", api._component_editors) + _win.close() + _win.deleteLater() def test_plugin_can_be_deactivated_and_reactivated(self) -> None: from plugins.electrical_propulsion.plugin import ElectricalPropulsionPlugin @@ -63,6 +65,8 @@ def test_plugin_can_be_deactivated_and_reactivated(self) -> None: manager.activate_plugin(plugin.id) self.assertIn("org.setuav.core:motor", api._component_editors) + window.close() + window.deleteLater() def test_motor_editor(self) -> None: api = StudioAPI() @@ -84,6 +88,8 @@ def test_motor_editor(self) -> None: # Undo api.undo() self.assertEqual(motor_comp["parameters"]["kv"], 900.0) + editor.close() + editor.deleteLater() def test_battery_editor(self) -> None: api = StudioAPI() @@ -110,6 +116,8 @@ def test_battery_editor(self) -> None: ) + float(params.get("packaging_mass", 40.0)) self.assertEqual(battery_comp["mass"], expected_mass) self.assertEqual(editor._property_text(editor.general_table, 2), f"{expected_mass:.1f}") + editor.close() + editor.deleteLater() def test_esc_editor(self) -> None: api = StudioAPI() @@ -120,6 +128,8 @@ def test_esc_editor(self) -> None: editor = EscEditor(api, esc_comp) self.assertEqual(editor._property_text(editor.parameters_table, 0), "50.0") + editor.close() + editor.deleteLater() def test_propeller_editor(self) -> None: api = StudioAPI() @@ -136,6 +146,8 @@ def test_propeller_editor(self) -> None: self.assertEqual(editor._property_text(editor.parameters_table, 0), dia) self.assertEqual(editor._property_text(editor.parameters_table, 1), pitch) self.assertEqual(editor._property_text(editor.parameters_table, 2), "2") + editor.close() + editor.deleteLater() def test_assembly_editor(self) -> None: api = StudioAPI() @@ -153,6 +165,8 @@ def test_assembly_editor(self) -> None: self.assertEqual(editor._property_text(editor.members_table, 0), "battery-main") self.assertEqual(editor._property_text(editor.members_table, 2), "motor-cruise") self.assertEqual(editor._property_text(editor.members_table, 3), "propeller-cruise") + editor.close() + editor.deleteLater() def test_catalog_database_and_dialog(self) -> None: from plugins.electrical_propulsion.catalog_dialog import ( @@ -173,6 +187,8 @@ def test_catalog_database_and_dialog(self) -> None: self.assertIsNotNone(dialog) dialog.motor_search.setText("Tiger") self.assertLessEqual(dialog.motor_table.rowCount(), 400) + dialog.close() + dialog.deleteLater() def test_propulsion_controls_and_analysis_run(self) -> None: from plugins.electrical_propulsion.plugin import ElectricalPropulsionPlugin @@ -208,6 +224,8 @@ def test_propulsion_controls_and_analysis_run(self) -> None: self.assertGreater(len(charts.chart_thrust_power.series()), 0) self.assertGreater(len(charts.chart_electrical.series()), 0) self.assertGreater(len(charts.chart_efficiency.series()), 0) + win.close() + win.deleteLater() def test_analysis_posts_status_messages(self) -> None: from plugins.electrical_propulsion.plugin import ElectricalPropulsionPlugin @@ -235,6 +253,8 @@ def test_analysis_posts_status_messages(self) -> None: controls.run_button.click() self._drain_events() self.assertIn("Current limit exceeded", win._status_label.text()) + win.close() + win.deleteLater() def test_propulsion_worker_and_solver_modes(self) -> None: from PySide6.QtCore import QThreadPool diff --git a/tests/suites.py b/tests/suites.py index d4de1a0a..125da94a 100644 --- a/tests/suites.py +++ b/tests/suites.py @@ -11,20 +11,18 @@ CORE_MODULES = ( "tests.core.test_about_dialog", + "tests.core.test_api_edges", + "tests.core.test_atmosphere", "tests.core.test_command_palette", "tests.core.test_component_editor", "tests.core.test_configuration", - "tests.core.test_configuration_ui", "tests.core.test_constraint", "tests.core.test_expression", - "tests.core.test_instance", "tests.core.test_main", "tests.core.test_model", "tests.core.test_native_models", "tests.core.test_parameter", - "tests.core.test_parameters_panel", - "tests.core.test_api_edges", - "tests.core.test_atmosphere", + "tests.core.test_plugin_installer", "tests.core.test_plugins", "tests.core.test_project", "tests.core.test_project_edge_cases", @@ -35,15 +33,18 @@ "tests.core.test_tasks", "tests.core.test_theme", "tests.core.test_units", + "tests.core.test_universal_scope", "tests.core.test_workspaces", ) GEOMETRY_MODULES = ( "tests.geometry.test_creation", "tests.geometry.test_envelope", "tests.geometry.test_geometry", + "tests.geometry.test_motor_mount", "tests.geometry.test_settings", "tests.geometry.test_structural_system_editor", "tests.geometry.test_wing_driver_solver", + "tests.geometry.test_workspace_overlays", ) AERODYNAMICS_FAST_MODULES = ( "tests.aerodynamics.test_aero_engine_base", @@ -55,7 +56,10 @@ "tests.aerodynamics.integration.test_stability_engine", "tests.aerodynamics.integration.test_sweep_infrastructure", ) -ELECTRICAL_PROPULSION_MODULES = ("tests.electrical_propulsion.test_creation",) +ELECTRICAL_PROPULSION_MODULES = ( + "tests.electrical_propulsion.test_creation", + "tests.electrical_propulsion.test_electrical_propulsion", +) FLIGHT_PERFORMANCE_MODULES = ("tests.flight_performance.test_flight_performance",) WEIGHT_BALANCE_MODULES = ("tests.weight_balance.test_weight_balance",) diff --git a/tests/weight_balance/test_weight_balance.py b/tests/weight_balance/test_weight_balance.py index f9c33ccf..85a43aae 100644 --- a/tests/weight_balance/test_weight_balance.py +++ b/tests/weight_balance/test_weight_balance.py @@ -18,11 +18,11 @@ WorkspaceContribution, ) from setuav_studio.project import ProjectDocument, open_project -from setuav_studio.ui.project_explorer import ( +from setuav_studio.ui.panels.project_explorer import ( ProjectExplorer, ProjectExplorerPanel, ) -from setuav_studio.ui.properties.properties_panel import PropertiesPanel +from setuav_studio.ui.panels.properties.properties_panel import PropertiesPanel from setuav_studio.ui.shell.native_registrations import register_native_contributions from tests._common import TEST_PROJECT_PATH, get_qapp