Files
Jackify/jackify/frontends/gui/services/message_service.py
T

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 logging
import os
import random
import string
import subprocess
import warnings
from typing import Optional
logger = logging.getLogger(__name__)
def open_url(url: str) -> None:
"""Open a URL in the system browser, safe to call from within an AppImage."""
env = os.environ.copy()
if "APPIMAGE" in env or "APPDIR" in env:
for var in ("LD_LIBRARY_PATH", "PYTHONPATH", "PYTHONHOME", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH"):
env.pop(var, None)
try:
subprocess.Popen(
["xdg-open", url], env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True,
)
except Exception as e:
logger.warning("Failed to open URL %s: %s", url, e)
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):
if critical or safety_level == "high":
self.setWindowModality(Qt.ApplicationModal)
else:
self.setWindowModality(Qt.NonModal)
self.setAttribute(Qt.WA_ShowWithoutActivating, True)
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'):
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
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()