Files
Jackify/jackify/frontends/gui/services/message_service.py
2026-04-20 20:57:23 +01:00

438 lines
18 KiB
Python

"""
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()