diff --git a/src/ui/dialogs/__init__.py b/src/ui/dialogs/__init__.py index a918560..70a311f 100644 --- a/src/ui/dialogs/__init__.py +++ b/src/ui/dialogs/__init__.py @@ -1,5 +1,6 @@ """Dialog components for the Plugin Configurator application.""" from ui.dialogs.preset_management_dialog import PresetManagementDialog +from ui.dialogs.success_dialog import SuccessDialog -__all__ = ["PresetManagementDialog"] +__all__ = ["PresetManagementDialog", "SuccessDialog"] diff --git a/src/ui/dialogs/success_dialog.py b/src/ui/dialogs/success_dialog.py new file mode 100644 index 0000000..30f4a6f --- /dev/null +++ b/src/ui/dialogs/success_dialog.py @@ -0,0 +1,274 @@ +"""Success dialog shown after a project is generated successfully.""" + +from __future__ import annotations + +import platform +import shutil +import subprocess +from pathlib import Path + +from PySide6.QtCore import Qt, QTimer, QUrl, Slot +from PySide6.QtGui import QDesktopServices +from PySide6.QtWidgets import ( + QDialog, + QDialogButtonBox, + QFrame, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +# Emoji frames for the celebration animation +_CELEBRATION_FRAMES = ["🎉", "🎊", "✨", "🌟", "⭐", "✨", "🎊", "🎉"] + +# IDE definitions: (display_name, executable, args_before_path) +_IDE_DEFINITIONS: list[tuple[str, str, list[str]]] = [ + ("VSCode", "code", ["."]), + ("CLion", "clion", ["."]), + ("Xcode", "xcode-select", []), # macOS only - we handle separately +] + + +def _detect_ides(project_path: str) -> list[tuple[str, callable]]: + """Return a list of (label, open_callable) for IDEs available on this machine. + + Args: + project_path: Absolute path to the generated project directory. + + Returns: + List of (display_label, callable) tuples where calling the callable + opens the project in the corresponding IDE. + """ + available: list[tuple[str, callable]] = [] + current_os = platform.system() + + # VSCode + if shutil.which("code"): + def _open_vscode(path: str = project_path) -> None: + subprocess.Popen(["code", path]) + + available.append(("VSCode", _open_vscode)) + + # CLion + if shutil.which("clion"): + def _open_clion(path: str = project_path) -> None: + subprocess.Popen(["clion", path]) + + available.append(("CLion", _open_clion)) + + # Xcode (macOS only) + if current_os == "Darwin" and shutil.which("xcodebuild"): + def _open_xcode(path: str = project_path) -> None: + # Look for an .xcodeproj or .xcworkspace in the project directory + p = Path(path) + for pattern in ("*.xcworkspace", "*.xcodeproj"): + matches = list(p.glob(pattern)) + if matches: + subprocess.Popen(["open", str(matches[0])]) + return + # Fall back to opening the folder + subprocess.Popen(["open", path]) + + available.append(("Xcode", _open_xcode)) + + return available + + +def _open_in_file_manager(path: str) -> None: + """Open *path* in the native file manager, cross-platform.""" + current_os = platform.system() + if current_os == "Darwin": + subprocess.Popen(["open", path]) + elif current_os == "Windows": + subprocess.Popen(["explorer", path]) + elif shutil.which("xdg-open"): + # Linux / other POSIX - try xdg-open, fall back to QDesktopServices + subprocess.Popen(["xdg-open", path]) + else: + QDesktopServices.openUrl(QUrl.fromLocalFile(path)) + + +def _file_manager_label() -> str: + """Return the platform-appropriate label for the file manager action.""" + current_os = platform.system() + if current_os == "Darwin": + return "Open in Finder" + if current_os == "Windows": + return "Open in Explorer" + return "Open in Files" + + +class SuccessDialog(QDialog): + """Dialog displayed after a project is generated successfully. + + Features: + - Animated celebration header + - Project name and output location display + - "Open in IDE" buttons for each detected IDE (VSCode, Xcode, CLion) + - Platform-aware "Open in Finder/Explorer/Files" button + - "Close" button + - Green-accented styling for the success state + """ + + def __init__( + self, + project_name: str, + output_directory: str, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self._project_name = project_name + self._output_directory = output_directory + self._animation_index = 0 + + self.setWindowTitle("Project Generated Successfully") + self.setMinimumWidth(480) + self.setMinimumHeight(280) + self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + + self._setup_ui() + self._start_animation() + + # ------------------------------------------------------------------ + # UI construction + # ------------------------------------------------------------------ + + def _setup_ui(self) -> None: + """Build the dialog layout.""" + root = QVBoxLayout(self) + root.setContentsMargins(24, 24, 24, 20) + root.setSpacing(16) + + # Celebration header + header_frame = QFrame() + header_frame.setObjectName("successHeader") + header_frame.setStyleSheet( + "#successHeader {" + " background-color: #1e7e34;" + " border-radius: 8px;" + "}" + ) + header_layout = QVBoxLayout(header_frame) + header_layout.setContentsMargins(16, 12, 16, 12) + header_layout.setSpacing(4) + + self._animation_label = QLabel("🎉") + self._animation_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._animation_label.setStyleSheet("font-size: 32px;") + header_layout.addWidget(self._animation_label) + + success_text = QLabel("Project Generated Successfully!") + success_text.setAlignment(Qt.AlignmentFlag.AlignCenter) + success_text.setStyleSheet( + "color: #ffffff; font-size: 16px; font-weight: bold;" + ) + header_layout.addWidget(success_text) + + root.addWidget(header_frame) + + # Project info panel + info_frame = QFrame() + info_frame.setFrameShape(QFrame.Shape.StyledPanel) + info_layout = QVBoxLayout(info_frame) + info_layout.setContentsMargins(12, 10, 12, 10) + info_layout.setSpacing(6) + + name_row = QHBoxLayout() + name_title = QLabel("Project:") + name_title.setFixedWidth(80) + self._name_label = QLabel(self._project_name or "\u2014") + self._name_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + name_row.addWidget(name_title) + name_row.addWidget(self._name_label, stretch=1) + info_layout.addLayout(name_row) + + path_row = QHBoxLayout() + path_title = QLabel("Location:") + path_title.setFixedWidth(80) + self._path_label = QLabel(self._output_directory or "\u2014") + self._path_label.setWordWrap(True) + self._path_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + path_row.addWidget(path_title) + path_row.addWidget(self._path_label, stretch=1) + info_layout.addLayout(path_row) + + root.addWidget(info_frame) + + # Action buttons + actions_layout = QVBoxLayout() + actions_layout.setSpacing(8) + + # File-manager button (always shown) + fm_btn = QPushButton(f"\U0001f4c1 {_file_manager_label()}") + fm_btn.setMinimumHeight(36) + fm_btn.setToolTip(f"Open the project folder:\n{self._output_directory}") + fm_btn.clicked.connect(self._on_open_in_file_manager) + actions_layout.addWidget(fm_btn) + + # IDE buttons (only for detected IDEs) + self._ide_actions = _detect_ides(self._output_directory) + if self._ide_actions: + ide_row = QHBoxLayout() + ide_row.setSpacing(8) + for ide_label, ide_fn in self._ide_actions: + btn = self._make_ide_button(ide_label, ide_fn) + ide_row.addWidget(btn) + actions_layout.addLayout(ide_row) + + root.addLayout(actions_layout) + + # Dialog close button + button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + button_box.rejected.connect(self.accept) + root.addWidget(button_box) + + @staticmethod + def _make_ide_button(label: str, callback: callable) -> QPushButton: + """Return a styled IDE button that calls *callback* when clicked.""" + icon_map = {"VSCode": "\U0001f4bb", "Xcode": "\U0001f528", "CLion": "\U0001f6e0"} + icon = icon_map.get(label, "\U0001f5a5") + btn = QPushButton(f"{icon} Open in {label}") + btn.setMinimumHeight(36) + btn.setToolTip(f"Open the project in {label}") + btn.clicked.connect(callback) + return btn + + # ------------------------------------------------------------------ + # Animation + # ------------------------------------------------------------------ + + def _start_animation(self) -> None: + """Start the celebration emoji cycling animation.""" + self._timer = QTimer(self) + self._timer.setInterval(400) + self._timer.timeout.connect(self._advance_frame) + self._timer.start() + + @Slot() + def _advance_frame(self) -> None: + """Advance to the next animation frame.""" + self._animation_index = (self._animation_index + 1) % len(_CELEBRATION_FRAMES) + self._animation_label.setText(_CELEBRATION_FRAMES[self._animation_index]) + + # ------------------------------------------------------------------ + # Slots + # ------------------------------------------------------------------ + + @Slot() + def _on_open_in_file_manager(self) -> None: + """Open the project directory in the native file manager.""" + if self._output_directory: + _open_in_file_manager(self._output_directory) + + def closeEvent(self, event) -> None: + """Stop the animation timer before closing.""" + self._timer.stop() + super().closeEvent(event) + diff --git a/src/ui/tabs/generate_tab.py b/src/ui/tabs/generate_tab.py index fa83f3e..a5d2963 100644 --- a/src/ui/tabs/generate_tab.py +++ b/src/ui/tabs/generate_tab.py @@ -1,7 +1,6 @@ """Generate Tab - summary review and project generation.""" -from PySide6.QtCore import Qt, QThread, QUrl, Slot -from PySide6.QtGui import QDesktopServices +from PySide6.QtCore import Qt, QThread, Slot from PySide6.QtWidgets import ( QGroupBox, QHBoxLayout, @@ -19,6 +18,7 @@ from core.base_tab import BaseTab from core.project_worker import ProjectWorker from ui.components.validation_footer import ValidationFooter +from ui.dialogs.success_dialog import SuccessDialog class GenerateTab(BaseTab): @@ -515,15 +515,9 @@ def _on_generation_finished(self) -> None: self._log_text.append("\n=== Generation Complete ===") self._log_text.append("Project generated successfully!") - output_dir = self._full_config.get("project_info", {}).get("output_directory", "") - - msg_box = QMessageBox(self) - msg_box.setWindowTitle("Project Generated") - msg_box.setText(f"Project generated successfully!\n\nLocation: {output_dir}") - msg_box.setIcon(QMessageBox.Icon.Information) - open_btn = msg_box.addButton("Open Folder", QMessageBox.ButtonRole.ActionRole) - msg_box.addButton("Close", QMessageBox.ButtonRole.RejectRole) - msg_box.exec() + project_info = self._full_config.get("project_info", {}) + project_name = project_info.get("project_name", "") + output_dir = project_info.get("output_directory", "") - if msg_box.clickedButton() is open_btn and output_dir: - QDesktopServices.openUrl(QUrl.fromLocalFile(output_dir)) + dlg = SuccessDialog(project_name, output_dir, parent=self) + dlg.exec() diff --git a/tests/test_success_dialog.py b/tests/test_success_dialog.py new file mode 100644 index 0000000..3417318 --- /dev/null +++ b/tests/test_success_dialog.py @@ -0,0 +1,319 @@ +"""Tests for the SuccessDialog.""" + +from __future__ import annotations + +import sys +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from ui.dialogs.success_dialog import ( + SuccessDialog, + _CELEBRATION_FRAMES, + _detect_ides, + _file_manager_label, + _open_in_file_manager, +) + + +@pytest.fixture(scope="module") +def app(): + """Single QApplication for the module.""" + instance = QApplication.instance() + if not instance: + instance = QApplication(sys.argv) + yield instance + + +@pytest.fixture +def dialog(app): + """Fresh SuccessDialog for each test.""" + dlg = SuccessDialog("MyPlugin", "/tmp/output") + dlg._timer.stop() # don't let the timer fire during tests + yield dlg + dlg.deleteLater() + + +# --------------------------------------------------------------------------- +# Initialisation +# --------------------------------------------------------------------------- + + +class TestSuccessDialogInit: + def test_dialog_is_not_none(self, dialog): + assert dialog is not None + + def test_dialog_is_instance_of_success_dialog(self, dialog): + assert isinstance(dialog, SuccessDialog) + + def test_window_title(self, dialog): + assert "Successfully" in dialog.windowTitle() + + def test_project_name_stored(self, dialog): + assert dialog._project_name == "MyPlugin" + + def test_output_directory_stored(self, dialog): + assert dialog._output_directory == "/tmp/output" + + def test_animation_index_starts_at_zero(self, dialog): + assert dialog._animation_index == 0 + + def test_animation_label_exists(self, dialog): + assert dialog._animation_label is not None + + def test_animation_label_initial_frame(self, dialog): + assert dialog._animation_label.text() == _CELEBRATION_FRAMES[0] + + def test_name_label_shows_project_name(self, dialog): + assert dialog._name_label.text() == "MyPlugin" + + def test_path_label_shows_output_directory(self, dialog): + assert dialog._path_label.text() == "/tmp/output" + + def test_minimum_width(self, dialog): + assert dialog.minimumWidth() >= 480 + + def test_minimum_height(self, dialog): + assert dialog.minimumHeight() >= 280 + + +# --------------------------------------------------------------------------- +# Empty / edge-case inputs +# --------------------------------------------------------------------------- + + +class TestSuccessDialogEdgeCases: + def test_empty_project_name_shows_em_dash(self, app): + dlg = SuccessDialog("", "/tmp/out") + dlg._timer.stop() + assert dlg._name_label.text() == "\u2014" + dlg.deleteLater() + + def test_empty_output_directory_shows_em_dash(self, app): + dlg = SuccessDialog("MyPlugin", "") + dlg._timer.stop() + assert dlg._path_label.text() == "\u2014" + dlg.deleteLater() + + def test_none_parent_accepted(self, app): + dlg = SuccessDialog("P", "/p", parent=None) + dlg._timer.stop() + assert dlg is not None + dlg.deleteLater() + + +# --------------------------------------------------------------------------- +# Animation +# --------------------------------------------------------------------------- + + +class TestSuccessDialogAnimation: + def test_advance_frame_cycles(self, dialog): + dialog._animation_index = 0 + dialog._advance_frame() + assert dialog._animation_index == 1 + assert dialog._animation_label.text() == _CELEBRATION_FRAMES[1] + + def test_advance_frame_wraps_around(self, dialog): + dialog._animation_index = len(_CELEBRATION_FRAMES) - 1 + dialog._advance_frame() + assert dialog._animation_index == 0 + assert dialog._animation_label.text() == _CELEBRATION_FRAMES[0] + + def test_all_frames_are_strings(self): + for frame in _CELEBRATION_FRAMES: + assert isinstance(frame, str) + assert len(frame) > 0 + + def test_timer_is_created(self, dialog): + assert dialog._timer is not None + + def test_timer_interval(self, dialog): + assert dialog._timer.interval() == 400 + + def test_close_event_stops_timer(self, app): + dlg = SuccessDialog("P", "/p") + assert dlg._timer.isActive() + dlg.close() + assert not dlg._timer.isActive() + dlg.deleteLater() + + +# --------------------------------------------------------------------------- +# IDE detection helpers +# --------------------------------------------------------------------------- + + +class TestDetectIdes: + def test_returns_list(self): + result = _detect_ides("/tmp") + assert isinstance(result, list) + + def test_no_ides_when_none_available(self): + with patch("shutil.which", return_value=None): + result = _detect_ides("/tmp") + assert result == [] + + def test_vscode_detected_when_code_on_path(self): + def fake_which(name): + return "/usr/bin/code" if name == "code" else None + + with patch("shutil.which", side_effect=fake_which): + result = _detect_ides("/tmp") + + labels = [label for label, _ in result] + assert "VSCode" in labels + + def test_clion_detected_when_clion_on_path(self): + def fake_which(name): + return "/usr/local/bin/clion" if name == "clion" else None + + with patch("shutil.which", side_effect=fake_which): + result = _detect_ides("/tmp") + + labels = [label for label, _ in result] + assert "CLion" in labels + + def test_xcode_detected_only_on_macos(self): + def fake_which(name): + return "/usr/bin/xcodebuild" if name == "xcodebuild" else None + + with ( + patch("shutil.which", side_effect=fake_which), + patch("platform.system", return_value="Darwin"), + ): + result = _detect_ides("/tmp") + + labels = [label for label, _ in result] + assert "Xcode" in labels + + def test_xcode_not_detected_on_linux(self): + def fake_which(name): + return "/usr/bin/xcodebuild" if name == "xcodebuild" else None + + with ( + patch("shutil.which", side_effect=fake_which), + patch("platform.system", return_value="Linux"), + ): + result = _detect_ides("/tmp") + + labels = [label for label, _ in result] + assert "Xcode" not in labels + + def test_ide_entry_is_callable(self): + def fake_which(name): + return "/usr/bin/code" if name == "code" else None + + with patch("shutil.which", side_effect=fake_which): + result = _detect_ides("/tmp") + + for _label, fn in result: + assert callable(fn) + + +# --------------------------------------------------------------------------- +# File manager helpers +# --------------------------------------------------------------------------- + + +class TestFileManagerLabel: + def test_macos_label(self): + with patch("platform.system", return_value="Darwin"): + assert _file_manager_label() == "Open in Finder" + + def test_windows_label(self): + with patch("platform.system", return_value="Windows"): + assert _file_manager_label() == "Open in Explorer" + + def test_linux_label(self): + with patch("platform.system", return_value="Linux"): + assert _file_manager_label() == "Open in Files" + + def test_unknown_os_label(self): + with patch("platform.system", return_value="FreeBSD"): + assert _file_manager_label() == "Open in Files" + + +class TestOpenInFileManager: + def test_macos_calls_open(self): + with ( + patch("platform.system", return_value="Darwin"), + patch("subprocess.Popen") as mock_popen, + ): + _open_in_file_manager("/tmp/project") + mock_popen.assert_called_once_with(["open", "/tmp/project"]) + + def test_windows_calls_explorer(self): + with ( + patch("platform.system", return_value="Windows"), + patch("subprocess.Popen") as mock_popen, + ): + _open_in_file_manager("/tmp/project") + mock_popen.assert_called_once_with(["explorer", "/tmp/project"]) + + def test_linux_with_xdg_open(self): + with ( + patch("platform.system", return_value="Linux"), + patch("shutil.which", return_value="/usr/bin/xdg-open"), + patch("subprocess.Popen") as mock_popen, + ): + _open_in_file_manager("/tmp/project") + mock_popen.assert_called_once_with(["xdg-open", "/tmp/project"]) + + def test_linux_fallback_to_desktop_services(self, app): + with ( + patch("platform.system", return_value="Linux"), + patch("shutil.which", return_value=None), + patch("ui.dialogs.success_dialog.QDesktopServices.openUrl") as mock_open, + ): + _open_in_file_manager("/tmp/project") + assert mock_open.called + + +# --------------------------------------------------------------------------- +# Dialog action buttons +# --------------------------------------------------------------------------- + + +class TestSuccessDialogActions: + def test_open_in_file_manager_slot_calls_helper(self, dialog, monkeypatch): + called_with = [] + monkeypatch.setattr( + "ui.dialogs.success_dialog._open_in_file_manager", + lambda path: called_with.append(path), + ) + dialog._output_directory = "/tmp/output" + dialog._on_open_in_file_manager() + assert called_with == ["/tmp/output"] + + def test_open_in_file_manager_noop_when_empty_dir(self, dialog, monkeypatch): + called = [] + monkeypatch.setattr( + "ui.dialogs.success_dialog._open_in_file_manager", + lambda path: called.append(path), + ) + dialog._output_directory = "" + dialog._on_open_in_file_manager() + assert called == [] + + def test_ide_buttons_created_for_detected_ides(self, app): + mock_fn = MagicMock() + with patch( + "ui.dialogs.success_dialog._detect_ides", + return_value=[("VSCode", mock_fn)], + ): + dlg = SuccessDialog("P", "/p") + dlg._timer.stop() + + labels = [label for label, _ in dlg._ide_actions] + assert "VSCode" in labels + dlg.deleteLater() + + def test_no_ide_buttons_when_none_detected(self, app): + with patch("ui.dialogs.success_dialog._detect_ides", return_value=[]): + dlg = SuccessDialog("P", "/p") + dlg._timer.stop() + + assert dlg._ide_actions == [] + dlg.deleteLater()