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