""" Non-Focus-Stealing Message Service for Jackify Provides message boxes that don't steal focus from the current application """ import random import string from typing import Optional from PySide6.QtWidgets import ( QMessageBox, QWidget, QLineEdit, QLabel, QVBoxLayout, QHBoxLayout, QCheckBox, QTextEdit, QPushButton, QDialog, QDialogButtonBox, QSizePolicy, QStyle, ) from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QFont class NonFocusMessageBox(QMessageBox): """Custom QMessageBox that prevents focus stealing""" def __init__(self, parent=None, critical=False, safety_level="low"): super().__init__(parent) self.safety_level = safety_level self._setup_no_focus_attributes(critical, safety_level) def _setup_no_focus_attributes(self, critical, safety_level): """Configure the message box to not steal focus""" # Set modality based on criticality and safety level if critical or safety_level == "high": self.setWindowModality(Qt.ApplicationModal) elif safety_level == "medium": self.setWindowModality(Qt.NonModal) else: self.setWindowModality(Qt.NonModal) # Prevent focus stealing self.setAttribute(Qt.WA_ShowWithoutActivating, True) self.setWindowFlags( self.windowFlags() | Qt.WindowStaysOnTopHint | Qt.WindowDoesNotAcceptFocus ) # Set focus policy to prevent taking focus self.setFocusPolicy(Qt.NoFocus) # Make sure child widgets don't steal focus either for child in self.findChildren(QWidget): child.setFocusPolicy(Qt.NoFocus) def showEvent(self, event): """Override to ensure no focus stealing on show""" super().showEvent(event) # Ensure we don't steal focus self.activateWindow() self.raise_() class SafeMessageBox(NonFocusMessageBox): """Enhanced message box with safety features""" def __init__(self, parent=None, safety_level="low"): super().__init__(parent, critical=(safety_level == "high"), safety_level=safety_level) self.safety_level = safety_level self.countdown_remaining = 0 self.confirmation_code = None self.countdown_timer = None self.code_input = None self.understanding_checkbox = None def setup_safety_features(self, title: str, message: str, danger_action: str = "OK", safe_action: str = "Cancel", is_question: bool = False): self.setWindowTitle(title) self.setText(message) if self.safety_level == "high": self.setIcon(QMessageBox.Warning) self._setup_high_safety(danger_action, safe_action) elif self.safety_level == "medium": self.setIcon(QMessageBox.Information) self._setup_medium_safety(danger_action, safe_action) else: self.setIcon(QMessageBox.Information) self._setup_low_safety(danger_action, safe_action) # --- Fix: For question dialogs, set proceed/cancel button return values, but do NOT call setStandardButtons --- if is_question and hasattr(self, 'proceed_btn'): self.proceed_btn.setText(danger_action) self.proceed_btn.setProperty('role', QMessageBox.YesRole) self.proceed_btn.clicked.disconnect() self.proceed_btn.clicked.connect(lambda: self.done(QMessageBox.Yes)) self.cancel_btn.setText(safe_action) self.cancel_btn.setProperty('role', QMessageBox.NoRole) self.cancel_btn.clicked.disconnect() self.cancel_btn.clicked.connect(lambda: self.done(QMessageBox.No)) def _setup_high_safety(self, danger_action: str, safe_action: str): """High safety: requires typing confirmation code""" # Generate random confirmation code self.confirmation_code = ''.join(random.choices(string.ascii_uppercase, k=6)) self.proceed_btn = self.addButton(danger_action, QMessageBox.ActionRole) self.cancel_btn = self.addButton(safe_action, QMessageBox.ActionRole) self.setDefaultButton(self.cancel_btn) self.proceed_btn.setEnabled(False) widget = QWidget() layout = QVBoxLayout(widget) instruction = QLabel(f"Type '{self.confirmation_code}' to confirm:") instruction.setStyleSheet("font-weight: bold; color: red;") layout.addWidget(instruction) self.code_input = QLineEdit() self.code_input.setPlaceholderText("Enter confirmation code...") self.code_input.textChanged.connect(self._check_code_input) layout.addWidget(self.code_input) self.layout().addWidget(widget, 1, 0, 1, self.layout().columnCount()) # Start countdown self._start_countdown(3) def _setup_medium_safety(self, danger_action: str, safe_action: str): """Medium safety: requires wait period""" self._danger_action_text = danger_action self.proceed_btn = self.addButton(danger_action, QMessageBox.ActionRole) self.cancel_btn = self.addButton(safe_action, QMessageBox.ActionRole) self.setDefaultButton(self.cancel_btn) self.proceed_btn.setEnabled(False) self._start_countdown(3) def _setup_low_safety(self, danger_action: str, safe_action: str): """Low safety: no additional features needed""" self.proceed_btn = self.addButton(danger_action, QMessageBox.ActionRole) self.cancel_btn = self.addButton(safe_action, QMessageBox.ActionRole) self.setDefaultButton(self.proceed_btn) def _start_countdown(self, seconds: int): self.countdown_timer = QTimer() self.countdown_timer.timeout.connect(self._update_countdown) self.countdown_remaining = seconds self._update_countdown() self.countdown_timer.start(1000) # Update every second def _update_countdown(self): if self.countdown_remaining > 0: if hasattr(self, 'proceed_btn'): if self.safety_level == "high": self.proceed_btn.setText(f"Please wait {self.countdown_remaining}s...") else: action_label = getattr(self, "_danger_action_text", "OK") self.proceed_btn.setText(f"{action_label} ({self.countdown_remaining}s)") self.proceed_btn.setEnabled(False) if hasattr(self, 'cancel_btn'): self.cancel_btn.setEnabled(False) self.countdown_remaining -= 1 else: self.countdown_timer.stop() if hasattr(self, 'proceed_btn'): if self.safety_level == "high": self.proceed_btn.setText("Proceed") else: self.proceed_btn.setText(getattr(self, "_danger_action_text", "OK")) self.proceed_btn.setEnabled(True) if hasattr(self, 'cancel_btn'): self.cancel_btn.setEnabled(True) self._check_all_requirements() def _check_code_input(self): """Check if typed code matches""" if self.countdown_remaining <= 0: self._check_all_requirements() def _check_all_requirements(self): """Check if all requirements are met""" can_proceed = self.countdown_remaining <= 0 if self.safety_level == "high": can_proceed = can_proceed and ( self.code_input.text().upper() == self.confirmation_code ) self.proceed_btn.setEnabled(can_proceed) class MessageService: """Service class for creating non-focus-stealing message boxes""" @staticmethod def _create_base_message_box(parent: Optional[QWidget] = None, critical: bool = False, safety_level: str = "low") -> NonFocusMessageBox: """Create a base message box with no focus stealing""" if safety_level in ["medium", "high"]: return SafeMessageBox(parent, safety_level) else: return NonFocusMessageBox(parent, critical) @staticmethod def information(parent: Optional[QWidget] = None, title: str = "Information", message: str = "", buttons: QMessageBox.StandardButtons = QMessageBox.Ok, default_button: QMessageBox.StandardButton = QMessageBox.Ok, critical: bool = False, safety_level: str = "low") -> int: """Show information message without stealing focus""" if safety_level in ["medium", "high"]: msg_box = SafeMessageBox(parent, safety_level) msg_box.setup_safety_features(title, message, "OK", "Cancel") else: msg_box = MessageService._create_base_message_box(parent, critical, safety_level) msg_box.setIcon(QMessageBox.Information) msg_box.setWindowTitle(title) msg_box.setTextFormat(Qt.RichText) msg_box.setTextInteractionFlags(Qt.TextBrowserInteraction) msg_box.setText(message) msg_box.setStandardButtons(buttons) msg_box.setDefaultButton(default_button) return msg_box.exec() @staticmethod def warning(parent: Optional[QWidget] = None, title: str = "Warning", message: str = "", buttons: QMessageBox.StandardButtons = QMessageBox.Ok, default_button: QMessageBox.StandardButton = QMessageBox.Ok, critical: bool = False, safety_level: str = "low") -> int: """Show warning message without stealing focus""" if safety_level in ["medium", "high"]: msg_box = SafeMessageBox(parent, safety_level) msg_box.setup_safety_features(title, message, "OK", "Cancel") else: msg_box = MessageService._create_base_message_box(parent, critical, safety_level) msg_box.setIcon(QMessageBox.Warning) msg_box.setWindowTitle(title) msg_box.setText(message) msg_box.setStandardButtons(buttons) msg_box.setDefaultButton(default_button) return msg_box.exec() @staticmethod def critical(parent: Optional[QWidget] = None, title: str = "Critical Error", message: str = "", buttons: QMessageBox.StandardButtons = QMessageBox.Ok, default_button: QMessageBox.StandardButton = QMessageBox.Ok, safety_level: str = "medium") -> int: """Show critical error message (always requires attention)""" msg_box = MessageService._create_base_message_box(parent, critical=True, safety_level=safety_level) msg_box.setIcon(QMessageBox.Critical) msg_box.setWindowTitle(title) msg_box.setText(message) msg_box.setStandardButtons(buttons) msg_box.setDefaultButton(default_button) return msg_box.exec() @staticmethod def question(parent: Optional[QWidget] = None, title: str = "Question", message: str = "", buttons: QMessageBox.StandardButtons = QMessageBox.Yes | QMessageBox.No, default_button: QMessageBox.StandardButton = QMessageBox.No, critical: bool = False, safety_level: str = "low") -> int: """Show question dialog without stealing focus. Uses explicit button order for consistency.""" if safety_level in ["medium", "high"]: msg_box = SafeMessageBox(parent, safety_level) msg_box.setup_safety_features(title, message, "Yes", "No", is_question=True) else: msg_box = MessageService._create_base_message_box(parent, critical, safety_level) msg_box.setIcon(QMessageBox.Question) msg_box.setWindowTitle(title) msg_box.setText(message) yes_btn = msg_box.addButton("Yes", QMessageBox.ActionRole) no_btn = msg_box.addButton("No", QMessageBox.ActionRole) if default_button == QMessageBox.No: msg_box.setDefaultButton(no_btn) else: msg_box.setDefaultButton(yes_btn) result = msg_box.exec() # For SafeMessageBox with is_question=True, return value is already set by done() if safety_level in ["medium", "high"]: return result # For non-SafeMessageBox, map clicked button to QMessageBox.Yes/No for compatibility clicked = msg_box.clickedButton() if clicked and clicked.text() == "Yes": return QMessageBox.Yes return QMessageBox.No @staticmethod def show_error(parent: Optional[QWidget], error) -> None: """Show a structured error dialog for a JackifyError. Displays title, plain-English message, optional "what to do" suggestion, and an optional collapsible technical detail pane. Args: parent: Parent widget (may be None). error: A JackifyError instance (imported inside to preserve backend/frontend separation). """ from jackify.shared.errors import JackifyError if not isinstance(error, JackifyError): # Fallback for plain exceptions dialog = _ErrorDialog(parent, str(error), str(error), None, [], None) dialog.exec() return dialog = _ErrorDialog( parent, error.title, error.message, error.suggestion, getattr(error, 'solutions', []), error.technical, ) dialog.exec() class _ErrorDialog(QDialog): """Internal dialog used by MessageService.show_error().""" _DETAIL_HEIGHT = 140 def __init__(self, parent, title: str, message: str, suggestion: Optional[str], solutions, technical: Optional[str]): super().__init__(parent) self.setWindowTitle(title) self.setWindowModality(Qt.ApplicationModal) self.setAttribute(Qt.WA_DeleteOnClose) self._technical = technical self._detail_visible = False layout = QVBoxLayout(self) layout.setSpacing(10) # Icon + message row icon_label = QLabel() icon_label.setPixmap( self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxCritical).pixmap(32, 32) ) icon_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) msg_label = QLabel(message) msg_label.setWordWrap(True) msg_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) top_row = QHBoxLayout() top_row.addWidget(icon_label) top_row.addWidget(msg_label, 1) layout.addLayout(top_row) # Suggestion row if suggestion: sug_label = QLabel(f"What to do: {suggestion}") sug_label.setWordWrap(True) sug_label.setStyleSheet("color: #aaaaaa; padding-left: 42px;") layout.addWidget(sug_label) # Numbered solutions list if solutions: steps_label = QLabel("Things to try:") steps_label.setStyleSheet("color: #cccccc; padding-left: 42px; font-weight: bold;") layout.addWidget(steps_label) for i, step in enumerate(solutions, start=1): step_label = QLabel(f" {i}. {step}") step_label.setWordWrap(True) step_label.setStyleSheet("color: #aaaaaa; padding-left: 52px;") layout.addWidget(step_label) # Technical detail toggle if technical: self._toggle_btn = QPushButton("Show technical detail") self._toggle_btn.setCheckable(False) self._toggle_btn.setStyleSheet( "QPushButton { text-align: left; border: none; color: #888888; " "padding: 0; font-size: 11px; } " "QPushButton:hover { color: #cccccc; }" ) self._toggle_btn.clicked.connect(self._toggle_detail) layout.addWidget(self._toggle_btn) self._detail_edit = QTextEdit() self._detail_edit.setReadOnly(True) self._detail_edit.setPlainText(technical) mono = QFont("Monospace") mono.setStyleHint(QFont.TypeWriter) self._detail_edit.setFont(mono) self._detail_edit.setStyleSheet( "background-color: #1a1a1a; color: #cccccc; " "border: 1px solid #333333; border-radius: 4px;" ) self._detail_edit.setFixedHeight(self._DETAIL_HEIGHT) self._detail_edit.hide() layout.addWidget(self._detail_edit) # OK button - disabled for 3s to prevent accidental dismissal buttons = QDialogButtonBox(QDialogButtonBox.Ok) buttons.accepted.connect(self.accept) layout.addWidget(buttons) self._ok_btn = buttons.button(QDialogButtonBox.Ok) self._ok_countdown = 3 self._ok_btn.setEnabled(False) self._ok_btn.setText(f"OK ({self._ok_countdown}s)") self._ok_timer = QTimer(self) self._ok_timer.timeout.connect(self._tick_ok_countdown) self._ok_timer.start(1000) self.setMinimumWidth(440) self.adjustSize() def _tick_ok_countdown(self): self._ok_countdown -= 1 if self._ok_countdown > 0: self._ok_btn.setText(f"OK ({self._ok_countdown}s)") else: self._ok_timer.stop() self._ok_btn.setText("OK") self._ok_btn.setEnabled(True) def _toggle_detail(self): self._detail_visible = not self._detail_visible if self._detail_visible: self._detail_edit.show() self._toggle_btn.setText("Hide technical detail") else: self._detail_edit.hide() self._toggle_btn.setText("Show technical detail") self.adjustSize()