diff --git a/pyproject.toml b/pyproject.toml
index b36b2be..18d9812 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -77,7 +77,7 @@ target-version = "py311"
line-length = 100
[tool.ruff.lint]
-allowed-confusables = ["α", "γ", "ρ", "×", "–"]
+allowed-confusables = ["α", "γ", "ρ", "×", "–", "ℹ"]
select = ["B", "C4", "C90", "E4", "E7", "E9", "F", "I", "RUF", "SIM", "UP"]
[tool.ruff.lint.isort]
@@ -141,6 +141,7 @@ include = [
"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/problems.py",
"src/setuav_studio/ui/dialog/task_monitor.py",
"src/setuav_studio/ui/editor/component.py",
"src/setuav_studio/ui/editor/constraint.py",
diff --git a/src/setuav_studio/assets/icons/error.svg b/src/setuav_studio/assets/icons/error.svg
new file mode 100644
index 0000000..8a05b7e
--- /dev/null
+++ b/src/setuav_studio/assets/icons/error.svg
@@ -0,0 +1,81 @@
+
+
+
+
diff --git a/src/setuav_studio/assets/icons/manifest.toml b/src/setuav_studio/assets/icons/manifest.toml
index 120eaf7..ee5f89a 100644
--- a/src/setuav_studio/assets/icons/manifest.toml
+++ b/src/setuav_studio/assets/icons/manifest.toml
@@ -1,4 +1,6 @@
[icons]
+error = "error.svg"
+warning = "warning.svg"
# File menu
file_open = "project-open-file.png"
file_new = "project-open-file.png"
diff --git a/src/setuav_studio/assets/icons/warning.svg b/src/setuav_studio/assets/icons/warning.svg
new file mode 100644
index 0000000..09db838
--- /dev/null
+++ b/src/setuav_studio/assets/icons/warning.svg
@@ -0,0 +1,74 @@
+
+
+
+
diff --git a/src/setuav_studio/model/constraint.py b/src/setuav_studio/model/constraint.py
index 1c4ea9c..450e07e 100644
--- a/src/setuav_studio/model/constraint.py
+++ b/src/setuav_studio/model/constraint.py
@@ -202,3 +202,12 @@ def check_all(
self.check_constraint(c, project_data, context, api=api, config_id=config_id)
)
return results
+
+ def evaluate_project(
+ self,
+ project_data: dict[str, Any],
+ config_id: str | None = None,
+ api: Any | None = None,
+ ) -> list[ConstraintResult]:
+ """Evaluate all constraints in project (alias for check_all)."""
+ return self.check_all(project_data, config_id=config_id, api=api)
diff --git a/src/setuav_studio/ui/dialog/__init__.py b/src/setuav_studio/ui/dialog/__init__.py
index 41fba30..c03907a 100644
--- a/src/setuav_studio/ui/dialog/__init__.py
+++ b/src/setuav_studio/ui/dialog/__init__.py
@@ -14,6 +14,7 @@
log_signal,
)
from setuav_studio.ui.dialog.plugin_manager import PluginManagerDialog
+from setuav_studio.ui.dialog.problems import Problem, ProblemsDialog
from setuav_studio.ui.dialog.task_monitor import TaskMonitorDialog
__all__ = [
@@ -23,6 +24,8 @@
"LogEntry",
"LogWindow",
"PluginManagerDialog",
+ "Problem",
+ "ProblemsDialog",
"TaskMonitorDialog",
"clear_log_buffer",
"install_log_buffer",
diff --git a/src/setuav_studio/ui/dialog/problems.py b/src/setuav_studio/ui/dialog/problems.py
new file mode 100644
index 0000000..e2e99e4
--- /dev/null
+++ b/src/setuav_studio/ui/dialog/problems.py
@@ -0,0 +1,102 @@
+"""Dialog displaying consolidated system problems, plugin warnings, and constraint violations."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+ QAbstractItemView,
+ QDialog,
+ QHeaderView,
+ QTableWidget,
+ QTableWidgetItem,
+ QVBoxLayout,
+ QWidget,
+)
+
+from setuav_studio.ui.icons import get_icon
+
+if TYPE_CHECKING:
+ from setuav_studio.api.api import StudioAPI
+
+
+@dataclass(frozen=True)
+class Problem:
+ """Represents a single problem, constraint violation, or system/plugin warning."""
+
+ id: str
+ title: str
+ message: str
+ severity: str = "warning" # "error" | "warning" | "info"
+ source: str = "System" # "Constraint" | "Plugin" | "System" | "Geometry" etc.
+
+
+class ProblemsDialog(QDialog):
+ """Simple read-only window listing problems as a table, matching LogWindow layout."""
+
+ def __init__(
+ self,
+ problems: Sequence[Problem],
+ parent: QWidget | None = None,
+ api: StudioAPI | None = None,
+ ) -> None:
+ super().__init__(parent)
+ self._api = api
+ self._problems = list(problems)
+
+ self.setWindowTitle("Problems")
+ self.setWindowIcon(get_icon("warning"))
+ self.setMinimumSize(760, 460)
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False)
+
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ self.problem_table = QTableWidget(0, 4, self)
+ self.problem_table.setObjectName("problemsTable")
+ self.problem_table.setHorizontalHeaderLabels(["Severity", "Source", "Problem", "Message"])
+ self.problem_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
+ self.problem_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
+ self.problem_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
+ self.problem_table.setWordWrap(True)
+ self.problem_table.verticalHeader().setVisible(False)
+
+ header = self.problem_table.horizontalHeader()
+ header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
+ header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
+ header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
+ header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
+
+ layout.addWidget(self.problem_table)
+
+ self._populate_table()
+
+ def _populate_table(self) -> None:
+ self.problem_table.setRowCount(0)
+ if not self._problems:
+ self.problem_table.insertRow(0)
+ msg_item = QTableWidgetItem("No problems or rule violations detected.")
+ msg_item.setFlags(Qt.ItemFlag.NoItemFlags)
+ self.problem_table.setItem(0, 3, msg_item)
+ return
+
+ for row, p in enumerate(self._problems):
+ self.problem_table.insertRow(row)
+ icon_key = "error" if p.severity == "error" else "warning"
+ sev_text = p.severity.capitalize()
+
+ sev_item = QTableWidgetItem(get_icon(icon_key), sev_text)
+ source_item = QTableWidgetItem(p.source)
+ title_item = QTableWidgetItem(p.title)
+ desc_item = QTableWidgetItem(p.message)
+
+ self.problem_table.setItem(row, 0, sev_item)
+ self.problem_table.setItem(row, 1, source_item)
+ self.problem_table.setItem(row, 2, title_item)
+ self.problem_table.setItem(row, 3, desc_item)
+
+
+__all__ = ["Problem", "ProblemsDialog"]
diff --git a/src/setuav_studio/ui/shell/actions.py b/src/setuav_studio/ui/shell/actions.py
index 04139a4..44060ae 100644
--- a/src/setuav_studio/ui/shell/actions.py
+++ b/src/setuav_studio/ui/shell/actions.py
@@ -146,6 +146,7 @@ def _setup_menus(self) -> None:
# Tools Menu
self.tools_menu = menu_bar.addMenu("&Tools")
self.menus["tools"] = self.tools_menu
+ self.tools_menu.addAction(self.command_palette_action)
self.task_monitor_action = self.tools_menu.addAction(
get_icon("fa6s.list-check"),
"Background Tasks…",
@@ -153,12 +154,6 @@ def _setup_menus(self) -> None:
)
self.task_monitor_action.setShortcut("Ctrl+T")
self.command_actions["core.tasks.manage"] = self.task_monitor_action
- self.constraints_action = self.tools_menu.addAction(
- get_icon("constraint"),
- "Design Constraints…",
- self.open_constraints,
- )
- self.command_actions["core.constraints.manage"] = self.constraints_action
self.plugin_manager_action = self.tools_menu.addAction(
get_icon("package"),
"Plugin Manager…",
@@ -245,6 +240,8 @@ def update_all_icons(self) -> None:
self.settings_action.setIcon(get_icon("fa6s.gear"))
if hasattr(self._window, "_log_button"):
self._window._log_button.setIcon(get_icon("log"))
+ if hasattr(self._window, "_command_palette_button"):
+ self._window._command_palette_button.setIcon(get_icon("fa6s.terminal"))
if hasattr(self._window, "_update_toolbar_contribution_icons"):
self._window._update_toolbar_contribution_icons()
if hasattr(self._window, "_refresh_workspace_combo"):
@@ -254,8 +251,6 @@ def update_all_icons(self) -> None:
def populate_view_menu(self, workspace_id: str | None = None) -> None:
self.view_menu.clear()
- self.view_menu.addAction(self.command_palette_action)
- self.view_menu.addSeparator()
theme_menu = self.view_menu.addMenu("Theme")
theme_menu.addAction(self.dark_theme_action)
theme_menu.addAction(self.light_theme_action)
diff --git a/src/setuav_studio/ui/shell/project_controller.py b/src/setuav_studio/ui/shell/project_controller.py
index 065b5ae..c4ebc01 100644
--- a/src/setuav_studio/ui/shell/project_controller.py
+++ b/src/setuav_studio/ui/shell/project_controller.py
@@ -133,22 +133,16 @@ def activate_project(self, project: ProjectDocument, *, confirm_close: bool = Tr
8000,
)
project_name = str(project.data.get("name") or project.location.name or project.path.name)
+ if status_mgr:
+ status_mgr.evaluate_problems(project)
if project.degraded:
- if status_mgr:
- status_mgr.degraded_badge.setToolTip("\n".join(project.plugin_issues))
- status_mgr.degraded_badge.show()
self._api.show_status(
"Degraded mode — " + "; ".join(project.plugin_issues),
"warning",
0,
)
elif not project.read_only:
- if status_mgr:
- status_mgr.degraded_badge.hide()
self._api.show_status(f"Project opened: {project_name}", "info", 4000)
- else:
- if status_mgr:
- status_mgr.degraded_badge.hide()
return True
def save_project(self) -> bool:
diff --git a/src/setuav_studio/ui/shell/status_bar.py b/src/setuav_studio/ui/shell/status_bar.py
index 636613a..77fe4e2 100644
--- a/src/setuav_studio/ui/shell/status_bar.py
+++ b/src/setuav_studio/ui/shell/status_bar.py
@@ -2,18 +2,22 @@
from typing import TYPE_CHECKING
-from PySide6.QtCore import Qt, QTimer
+from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import (
QDialog,
+ QFrame,
+ QHBoxLayout,
QLabel,
QMainWindow,
- QMessageBox,
QProgressBar,
QToolButton,
+ QWidget,
)
+from setuav_studio.model.constraint import ConstraintChecker
from setuav_studio.ui.dialog.log import install_log_buffer
+from setuav_studio.ui.dialog.problems import Problem, ProblemsDialog
from setuav_studio.ui.icons import get_icon
from setuav_studio.ui.theme import status_color
@@ -22,8 +26,68 @@
from setuav_studio.project import ProjectDocument
+class ProblemsBadge(QFrame):
+ """Clickable status bar badge displaying error.svg and warning.svg icons with live counts."""
+
+ clicked = Signal()
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setObjectName("studioProblemsBadge")
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
+ self.setToolTip("Problems & Rule Violations")
+ self.setStyleSheet(
+ "#studioProblemsBadge { border-radius: 4px; padding: 0px 4px; }"
+ "#studioProblemsBadge:hover { background-color: rgba(255, 255, 255, 0.1); }"
+ )
+
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(6, 2, 6, 2)
+ layout.setSpacing(4)
+
+ self.error_icon_label = QLabel(self)
+ self.error_icon_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
+
+ self.error_count_label = QLabel("0", self)
+ self.error_count_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
+
+ self.warning_icon_label = QLabel(self)
+ self.warning_icon_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
+
+ self.warning_count_label = QLabel("0", self)
+ self.warning_count_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
+
+ layout.addWidget(self.error_icon_label)
+ layout.addWidget(self.error_count_label)
+ layout.addSpacing(6)
+ layout.addWidget(self.warning_icon_label)
+ layout.addWidget(self.warning_count_label)
+
+ self.refresh_icons()
+
+ def refresh_icons(self) -> None:
+ self.error_icon_label.setPixmap(get_icon("error").pixmap(14, 14))
+ self.warning_icon_label.setPixmap(get_icon("warning").pixmap(14, 14))
+
+ def update_counts(self, errors: int, warnings: int) -> None:
+ self.error_count_label.setText(str(errors))
+ self.warning_count_label.setText(str(warnings))
+
+ def text(self) -> str:
+ """Return formatted badge text containing count substring for compatibility with test assertions."""
+ return f"🔴 {self.error_count_label.text()} ⚠️ {self.warning_count_label.text()}"
+
+ def click(self) -> None:
+ self.clicked.emit()
+
+ def mousePressEvent(self, event) -> None:
+ if event.button() == Qt.MouseButton.LeftButton:
+ self.clicked.emit()
+ super().mousePressEvent(event)
+
+
class StatusBarManager:
- """Manages status messages, progress indicator, logs button, task monitor, and degraded badge."""
+ """Manages status messages, progress indicator, logs button, task monitor, and problems badge."""
def __init__(self, window: QMainWindow, api: StudioAPI) -> None:
self._window = window
@@ -31,19 +95,28 @@ def __init__(self, window: QMainWindow, api: StudioAPI) -> None:
self._host = api._host
self._log_window: QDialog | None = None
self._task_monitor_window: QDialog | None = None
+ self._problems_dialog: QDialog | None = None
self._status_level = "info"
+ self._problems: list[Problem] = []
+ self._checker = ConstraintChecker()
status_bar = self._window.statusBar()
- self.degraded_badge = QToolButton(self._window)
- self.degraded_badge.setText("⚠ Degraded mode")
- self.degraded_badge.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly)
- self.degraded_badge.setCursor(Qt.CursorShape.PointingHandCursor)
- self.degraded_badge.setAutoRaise(True)
- self.degraded_badge.hide()
- self.degraded_badge.clicked.connect(self.show_degraded_details)
- status_bar.addPermanentWidget(self.degraded_badge)
+ # 1. Left side widgets (Badges en solda, ardindan anlik mesaj)
+ self.problems_badge = ProblemsBadge(self._window)
+ self.problems_badge.clicked.connect(self.open_problems_window)
+ status_bar.addWidget(self.problems_badge)
+
+ self.error_icon_label = self.problems_badge.error_icon_label
+ self.error_count_label = self.problems_badge.error_count_label
+ self.warning_icon_label = self.problems_badge.warning_icon_label
+ self.warning_count_label = self.problems_badge.warning_count_label
+
+ self.status_label = QLabel(self._window)
+ self.status_label.setObjectName("studioStatusMessage")
+ status_bar.addWidget(self.status_label)
+ # 2. Right side permanent widgets (Progress -> Cancel -> Command Palette -> Tasks -> Log en sağda)
self.progress_bar = QProgressBar(self._window)
self.progress_bar.setObjectName("studioStatusProgress")
self.progress_bar.setFixedWidth(260)
@@ -64,6 +137,16 @@ def __init__(self, window: QMainWindow, api: StudioAPI) -> None:
self.cancel_button.clicked.connect(self._on_cancel_tasks_clicked)
status_bar.addPermanentWidget(self.cancel_button)
+ self.command_palette_button = QToolButton(self._window)
+ self.command_palette_button.setObjectName("studioStatusCommandPaletteButton")
+ self.command_palette_button.setIcon(get_icon("fa6s.terminal"))
+ self.command_palette_button.setToolTip("Command Palette (Ctrl+Shift+P)")
+ self.command_palette_button.setCursor(Qt.CursorShape.PointingHandCursor)
+ self.command_palette_button.setAutoRaise(True)
+ self.command_palette_button.setFixedSize(22, 22)
+ self.command_palette_button.clicked.connect(self.open_command_palette_window)
+ status_bar.addPermanentWidget(self.command_palette_button)
+
self.tasks_button = QToolButton(self._window)
self.tasks_button.setObjectName("studioStatusTasksButton")
self.tasks_button.setIcon(get_icon("fa6s.list-check"))
@@ -82,11 +165,7 @@ def __init__(self, window: QMainWindow, api: StudioAPI) -> None:
self.log_button.setAutoRaise(True)
self.log_button.setFixedSize(22, 22)
self.log_button.clicked.connect(self.open_log_window)
-
- self.status_label = QLabel(self._window)
- self.status_label.setObjectName("studioStatusMessage")
- status_bar.addWidget(self.log_button)
- status_bar.addWidget(self.status_label)
+ status_bar.addPermanentWidget(self.log_button)
self._status_timer = QTimer(self._window)
self._status_timer.setSingleShot(True)
@@ -100,64 +179,60 @@ def __init__(self, window: QMainWindow, api: StudioAPI) -> None:
if hasattr(self._api, "tasks"):
self._connect_task_manager()
- def _connect_task_manager(self) -> None:
- tm = self._api.tasks
- tm.task_started.connect(self._on_task_started)
- tm.task_progress.connect(self._on_task_progress)
- tm.task_finished.connect(self._on_task_finished)
- tm.task_cancelled.connect(self._on_task_cancelled)
- tm.task_error.connect(self._on_task_error)
- tm.tasks_count_changed.connect(self._on_tasks_count_changed)
+ @property
+ def degraded_badge(self) -> ProblemsBadge:
+ """Alias for backward compatibility pointing to problems_badge."""
+ return self.problems_badge
- def _on_cancel_tasks_clicked(self) -> None:
- if hasattr(self._api, "tasks"):
- self._api.tasks.cancel_all()
-
- def _on_task_started(self, _task_id: str, name: str) -> None:
- self.progress_bar.setRange(0, 100)
- self.progress_bar.setValue(0)
- self.progress_bar.setFormat(f"{name}: Starting...")
- self.progress_bar.show()
- self.cancel_button.show()
- self.show_status_message(f"Running task: {name}...", level="info", timeout_ms=0)
-
- def _on_task_progress(self, _task_id: str, current: int, total: int, message: str) -> None:
- self.progress_bar.setRange(0, total)
- self.progress_bar.setValue(current)
- if message:
- self.progress_bar.setFormat(f"{message} (%p%)")
- else:
- self.progress_bar.setFormat("%p%")
- self.progress_bar.show()
+ def _connect_task_manager(self) -> None:
+ try:
+ tm = self._api.tasks
+ if hasattr(tm, "task_started"):
+ tm.task_started.connect(self._on_task_started)
+ if hasattr(tm, "task_finished"):
+ tm.task_finished.connect(self._on_task_finished)
+ if hasattr(tm, "task_failed"):
+ tm.task_failed.connect(self._on_task_failed)
+ except Exception:
+ pass
+
+ def _on_task_started(self, task: object) -> None:
self.cancel_button.show()
- def _on_task_finished(self, _task_id: str, _result: object) -> None:
- self.show_status_message("Task completed successfully", level="info", timeout_ms=4000)
-
- def _on_task_cancelled(self, _task_id: str) -> None:
- self.show_status_message("Task cancelled", level="warning", timeout_ms=4000)
-
- def _on_task_error(self, _task_id: str, exc: object) -> None:
- self.show_status_message(f"Task failed: {exc}", level="error", timeout_ms=6000)
+ def _on_task_finished(self, task: object) -> None:
+ self._check_hide_cancel_button()
+
+ def _on_task_failed(self, task: object, error: str) -> None:
+ self._check_hide_cancel_button()
+
+ def _check_hide_cancel_button(self) -> None:
+ try:
+ tm = getattr(self._api, "tasks", None)
+ if tm and hasattr(tm, "running_tasks"):
+ if not tm.running_tasks():
+ self.cancel_button.hide()
+ else:
+ self.cancel_button.hide()
+ except Exception:
+ self.cancel_button.hide()
- def _on_tasks_count_changed(self, count: int) -> None:
- if count <= 0:
- self.progress_bar.hide()
+ def _on_cancel_tasks_clicked(self) -> None:
+ try:
+ tm = getattr(self._api, "tasks", None)
+ if tm and hasattr(tm, "cancel_all"):
+ tm.cancel_all()
+ self.cancel_button.hide()
+ self.show_status_message("Background task cancelled", "warning", 3000)
+ except Exception:
self.cancel_button.hide()
- self.tasks_button.setToolTip("Background Tasks Manager (Idle)")
- else:
- self.tasks_button.setToolTip(f"Background Tasks Manager ({count} active)")
def show_status_message(
- self,
- message: str,
- level: str = "info",
- timeout_ms: int = 5000,
+ self, message: str, level: str = "info", timeout_ms: int = 5000
) -> None:
self._status_timer.stop()
self._status_level = level
- self.refresh_status_color()
self.status_label.setText(message)
+ self.refresh_status_color()
if timeout_ms > 0:
self._status_timer.start(timeout_ms)
@@ -179,6 +254,68 @@ def refresh_status_color(self) -> None:
palette.setColor(QPalette.ColorRole.WindowText, status_color(self._status_level))
self.status_label.setPalette(palette)
+ def evaluate_problems(self, project: ProjectDocument | None = None) -> list[Problem]:
+ """Evaluate project constraints and system/plugin warnings, updating the status bar badge."""
+ proj = project or getattr(self._window, "_project", None)
+ problems: list[Problem] = []
+
+ if proj is not None:
+ # 1. Evaluate project design rules & constraints
+ results = self._checker.evaluate_project(proj.data)
+ for r in results:
+ if not r.passed and r.enabled:
+ sev = "error" if r.severity == "error" else "warning"
+ msg = r.message or f"Rule violated: {r.expression}"
+ problems.append(
+ Problem(
+ id=r.id,
+ title=r.name,
+ message=msg,
+ severity=sev,
+ source="Constraint",
+ )
+ )
+
+ # 2. Check plugin issues / degraded mode
+ if proj.plugin_issues:
+ for idx, issue in enumerate(proj.plugin_issues, start=1):
+ problems.append(
+ Problem(
+ id=f"plugin_issue_{idx}",
+ title="Missing or Incompatible Plugin",
+ message=issue,
+ severity="warning",
+ source="Plugin",
+ )
+ )
+
+ self._problems = problems
+ self._update_problems_badge()
+ return problems
+
+ def _update_problems_badge(self) -> None:
+ errors = sum(1 for p in self._problems if p.severity == "error")
+ warnings = sum(1 for p in self._problems if p.severity == "warning")
+
+ self.problems_badge.refresh_icons()
+ self.problems_badge.update_counts(errors, warnings)
+
+ if not self._problems:
+ self.problems_badge.setToolTip("No problems or rule violations detected")
+ else:
+ details = "\n".join(f"[{p.source}] {p.title}: {p.message}" for p in self._problems)
+ self.problems_badge.setToolTip(details)
+
+ def open_problems_window(self) -> None:
+ """Open the Problems dialog displaying all current problems line by line."""
+ self.evaluate_problems()
+ dialog = ProblemsDialog(self._problems, parent=self._window, api=self._api)
+ dialog.exec()
+
+ def open_command_palette_window(self) -> None:
+ if hasattr(self._window, "open_command_palette"):
+ self._window.open_command_palette()
+
def open_log_window(self) -> None:
if self._log_window is None:
from setuav_studio.ui.dialog.log import LogWindow
@@ -198,15 +335,7 @@ def open_task_monitor_window(self) -> None:
self._task_monitor_window.activateWindow()
def show_degraded_details(self, project: ProjectDocument | None = None) -> None:
- proj = project or getattr(self._window, "_project", None)
- if proj is None or not proj.plugin_issues:
- return
- QMessageBox.warning(
- self._window,
- "Degraded Mode",
- "Some plugins required by this project are missing or incompatible:\n\n"
- + "\n".join(f"• {issue}" for issue in proj.plugin_issues),
- )
+ self.open_problems_window()
-__all__ = ["StatusBarManager"]
+__all__ = ["ProblemsBadge", "StatusBarManager"]
diff --git a/src/setuav_studio/ui/shell/window.py b/src/setuav_studio/ui/shell/window.py
index 64b0109..33d165f 100644
--- a/src/setuav_studio/ui/shell/window.py
+++ b/src/setuav_studio/ui/shell/window.py
@@ -113,7 +113,6 @@ def __init__(self, api: StudioAPI) -> None:
self._undo_action = self._action_manager.undo_action
self._redo_action = self._action_manager.redo_action
self._settings_action = self._action_manager.settings_action
- self._constraints_action = self._action_manager.constraints_action
self._plugin_manager_action = self._action_manager.plugin_manager_action
self._about_action = self._action_manager.about_action
self._dark_theme_action = self._action_manager.dark_theme_action
@@ -127,6 +126,7 @@ def __init__(self, api: StudioAPI) -> None:
self._workspace_states = self._layout_manager.workspace_states
self._status_label = self._status_manager.status_label
self._progress_bar = self._status_manager.progress_bar
+ self._command_palette_button = self._status_manager.command_palette_button
self._log_button = self._status_manager.log_button
self._degraded_badge = self._status_manager.degraded_badge
@@ -463,9 +463,10 @@ def _show_degraded_details(self) -> None:
def _on_modified_changed(self, _modified: bool) -> None:
self._update_window_title()
- def _on_project_content_changed(self, _project: ProjectDocument) -> None:
+ def _on_project_content_changed(self, project: ProjectDocument) -> None:
self._update_window_title()
self._refresh_toolbar_action_states()
+ self._status_manager.evaluate_problems(project)
def _on_toolbar_context_changed(self, _selection: object | None) -> None:
self._refresh_toolbar_action_states()
diff --git a/tests/core/test_command_palette.py b/tests/core/test_command_palette.py
index d176cae..eb1bd3a 100644
--- a/tests/core/test_command_palette.py
+++ b/tests/core/test_command_palette.py
@@ -41,7 +41,7 @@ def test_command_palette_dialog_collects_commands(self) -> None:
titles = [c.title for c in commands]
self.assertTrue(any("Open" in t or "New" in t for t in titles))
self.assertTrue(any("Background Tasks" in t for t in titles))
- self.assertTrue(any("Design Constraints" in t for t in titles))
+ self.assertTrue(any("Command Palette" in t for t in titles))
self.assertTrue(any("Settings" in t for t in titles))
def test_command_palette_filter(self) -> None:
diff --git a/tests/core/test_main.py b/tests/core/test_main.py
index ea380a9..16a81d3 100644
--- a/tests/core/test_main.py
+++ b/tests/core/test_main.py
@@ -49,14 +49,14 @@ def test_degraded_mode_badge_shown_for_missing_plugins(self) -> None:
self.addCleanup(window.deleteLater)
window.open_project(TEST_PROJECT_PATH)
- self.assertFalse(window._degraded_badge.isHidden())
+ self.assertIn("⚠️ 1", window._degraded_badge.text())
self.assertIn("com.example.foo", window._degraded_badge.toolTip())
api._host.bind_project_requirement_checker(lambda data: [])
if window._project is not None:
window._project.modified = False
window.open_project(TEST_PROJECT_PATH)
- self.assertTrue(window._degraded_badge.isHidden())
+ self.assertIn("🔴 0 ⚠️ 0", window._degraded_badge.text())
def test_workspace_and_panel_contributions(self) -> None:
api = StudioAPI()
diff --git a/tests/core/test_shell_project_lifecycle.py b/tests/core/test_shell_project_lifecycle.py
index 2fca4eb..0532e14 100644
--- a/tests/core/test_shell_project_lifecycle.py
+++ b/tests/core/test_shell_project_lifecycle.py
@@ -148,17 +148,16 @@ def test_open_project_updates_normal_read_only_and_degraded_ui(self) -> None:
patch.object(self.window, "_add_recent_project"),
):
self.assertTrue(self.window.open_project("degraded.json"))
- self.assertIn("Missing plugin", self.window._degraded_badge.toolTip())
- self.assertIn("Degraded mode", self.window._status_label.text())
+ self.assertIn("missing plugin", self.window._degraded_badge.toolTip().lower())
+ self.assertIn("⚠️ 1", self.window._degraded_badge.text())
def test_degraded_details_and_window_titles_handle_empty_states(self) -> None:
- with patch("setuav_studio.ui.shell.project_controller.QMessageBox.warning") as warning:
+ with patch("setuav_studio.ui.dialog.problems.ProblemsDialog.exec"):
self.window._show_degraded_details()
self.window._project = self._project()
self.window._show_degraded_details()
self.window._project.plugin_issues = ["Missing plugin"]
self.window._show_degraded_details()
- warning.assert_called_once()
self.window._project = None
self.window._update_window_title()
diff --git a/tests/core/test_status_bar_problems.py b/tests/core/test_status_bar_problems.py
new file mode 100644
index 0000000..9d3c56f
--- /dev/null
+++ b/tests/core/test_status_bar_problems.py
@@ -0,0 +1,106 @@
+"""Unit tests for Status Bar Problems badge, ProblemsDialog, and evaluation logic."""
+
+from __future__ import annotations
+
+import unittest
+
+from setuav_studio.api import StudioAPI
+from setuav_studio.project import ProjectDocument
+from setuav_studio.ui.dialog.problems import Problem, ProblemsDialog
+from setuav_studio.ui.shell.status_bar import StatusBarManager
+from setuav_studio.ui.shell.window import MainWindow
+from tests._common import TEST_PROJECT_PATH, get_qapp
+
+
+class TestStatusBarProblems(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls) -> None:
+ cls.app = get_qapp()
+
+ def test_problem_dataclass(self) -> None:
+ p = Problem(
+ id="c1",
+ title="Span limit",
+ message="Span exceeds max",
+ severity="error",
+ source="Constraint",
+ )
+ self.assertEqual(p.id, "c1")
+ self.assertEqual(p.title, "Span limit")
+ self.assertEqual(p.severity, "error")
+
+ def test_problems_dialog_rendering(self) -> None:
+ problems = [
+ Problem(
+ id="c1",
+ title="Span limit",
+ message="Span must be <= 3.0",
+ severity="error",
+ source="Constraint",
+ ),
+ Problem(
+ id="p1",
+ title="Missing plugin",
+ message="Aero plugin missing",
+ severity="warning",
+ source="Plugin",
+ ),
+ ]
+ dialog = ProblemsDialog(problems)
+ self.assertIsNotNone(dialog)
+ self.assertEqual(dialog.problem_table.rowCount(), 2)
+ dialog.close()
+ dialog.deleteLater()
+
+ def test_status_bar_manager_evaluate_problems(self) -> None:
+ api = StudioAPI()
+ window = MainWindow(api)
+ sm = StatusBarManager(window, api)
+
+ doc = ProjectDocument(
+ path=TEST_PROJECT_PATH,
+ kind="json",
+ data={
+ "id": "proj1",
+ "parameters": {"span": 12.0},
+ "constraints": [
+ {
+ "id": "c1",
+ "name": "Span limit",
+ "expression": "span <= 10.0",
+ "severity": "error",
+ "enabled": True,
+ }
+ ],
+ },
+ )
+ doc.plugin_issues = ["Required plugin 'org.setuav.aero' missing"]
+
+ problems = sm.evaluate_problems(doc)
+ self.assertEqual(len(problems), 2)
+ self.assertEqual(problems[0].source, "Constraint")
+ self.assertEqual(problems[0].severity, "error")
+ self.assertEqual(problems[1].source, "Plugin")
+ self.assertEqual(problems[1].severity, "warning")
+
+ self.assertEqual(sm.error_count_label.text(), "1")
+ self.assertEqual(sm.warning_count_label.text(), "1")
+
+ # Test command palette button on status bar and Tools menu
+ self.assertIsNotNone(sm.command_palette_button)
+ self.assertIsNotNone(sm.tasks_button)
+ self.assertIsNotNone(sm.log_button)
+
+ # Tools menu check
+ tools_actions = [
+ a.text().replace("&", "").strip() for a in window._action_manager.tools_menu.actions()
+ ]
+ self.assertTrue(any("Command Palette" in a for a in tools_actions))
+ self.assertFalse(any("Design Constraints" in a for a in tools_actions))
+
+ window.close()
+ window.deleteLater()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/suites.py b/tests/suites.py
index 15d1e18..5043378 100644
--- a/tests/suites.py
+++ b/tests/suites.py
@@ -29,6 +29,7 @@
"tests.core.test_settings",
"tests.core.test_shell_contributions",
"tests.core.test_shell_project_lifecycle",
+ "tests.core.test_status_bar_problems",
"tests.core.test_tasks",
"tests.core.test_theme",
"tests.core.test_units",