mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 01:47:45 +02:00
438 lines
18 KiB
Python
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()
|