diff --git a/Furious/Actions/AGENTS.md b/Furious/Actions/AGENTS.md
index 8aa45915..921c99f1 100644
--- a/Furious/Actions/AGENTS.md
+++ b/Furious/Actions/AGENTS.md
@@ -11,6 +11,8 @@
- Long or batched work must yield safely or use an owned worker/progress dialog. Do not sleep the GUI thread.
- Asynchronous prompts/editors use managed `open()` lifetime and connect completion before opening. Rebuilt dynamic
menus must release obsolete `QMenu`/`QAction` objects rather than accumulating them.
+- Preserve structured error semantics in message boxes: error title becomes the visible `heading`, the main message
+ becomes `text`, and diagnostics become `informativeText`; native `windowTitle` remains metadata.
## Code review rules
diff --git a/Furious/Actions/Connection.py b/Furious/Actions/Connection.py
index 8d299cb9..dfeb7582 100644
--- a/Furious/Actions/Connection.py
+++ b/Furious/Actions/Connection.py
@@ -128,7 +128,7 @@ class ConnectAction(AppQAction):
return
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
- mbox.setWindowTitle(error.title)
+ mbox.setHeading(error.title)
mbox.setText(error.message)
if error.details:
diff --git a/Furious/Actions/Import.py b/Furious/Actions/Import.py
index 0f043c6f..80da44b5 100644
--- a/Furious/Actions/Import.py
+++ b/Furious/Actions/Import.py
@@ -303,11 +303,8 @@ class MBoxImportError(AppQMessageBox):
"""Initialize the MBoxImportError."""
super().__init__(*args, **kwargs)
- self.setWindowTitle(_('Import'))
-
def retranslate(self):
"""Refresh translated text for the m box import error."""
- self.setWindowTitle(_(self.windowTitle()))
self.setText(_(self.text()))
# Ignore informative text, buttons
@@ -325,7 +322,6 @@ class MBoxImportMultiSuccess(AppQMessageBox):
self.imported = list()
self.rowIndex = 0
- self.setWindowTitle(_('Import'))
self.setIcon(AppQMessageBox.Icon.Information)
def customText(self):
@@ -351,7 +347,6 @@ class MBoxImportMultiSuccess(AppQMessageBox):
def retranslate(self):
"""Refresh translated text for the m box import multi success."""
- self.setWindowTitle(_(self.windowTitle()))
self.setText(self.customText())
# Ignore informative text, buttons
@@ -367,7 +362,6 @@ class MBoxImportSuccess(AppQMessageBox):
super().__init__(*args, **kwargs)
self.remark = ''
- self.setWindowTitle(_('Import'))
def customText(self):
"""Return the user-facing message text for the m box import success."""
@@ -378,7 +372,6 @@ class MBoxImportSuccess(AppQMessageBox):
def retranslate(self):
"""Refresh translated text for the m box import success."""
- self.setWindowTitle(_(self.windowTitle()))
self.setText(self.customText())
# Ignore informative text, buttons
@@ -413,7 +406,6 @@ class ImportFromFileAction(AppQAction):
# Any non-exit exceptions
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
- mbox.setWindowTitle(_('Error opening file'))
mbox.setText(_('Invalid configuration file'))
mbox.setInformativeText(str(ex))
diff --git a/Furious/Backends/ExternalCore/Editor.py b/Furious/Backends/ExternalCore/Editor.py
index e0b52f4e..88cd4aa1 100644
--- a/Furious/Backends/ExternalCore/Editor.py
+++ b/Furious/Backends/ExternalCore/Editor.py
@@ -499,13 +499,13 @@ class ExternalCoreEditor(GuiEditorWidgetQDialog):
)
if validationMessage:
- messageBox = AppQMessageBox(
+ mbox = AppQMessageBox(
icon=AppQMessageBox.Icon.Warning,
parent=self,
)
- messageBox.setWindowTitle(_('Invalid data'))
- messageBox.setText(validationMessage)
- messageBox.open()
+ mbox.setHeading(_('Invalid data'))
+ mbox.setText(validationMessage)
+ mbox.open()
return
diff --git a/Furious/Backends/Hysteria2/Editor.py b/Furious/Backends/Hysteria2/Editor.py
index f2e9102b..8a41a06b 100644
--- a/Furious/Backends/Hysteria2/Editor.py
+++ b/Furious/Backends/Hysteria2/Editor.py
@@ -1165,13 +1165,13 @@ class Hysteria2Editor(GuiEditorWidgetQDialog):
)
if mimicEnabled and ConfigHysteria2({'server': server}).usesPortHopping():
- messageBox = AppQMessageBox(
+ mbox = AppQMessageBox(
icon=AppQMessageBox.Icon.Warning,
- windowTitle=_('Invalid Configuration'),
+ heading=_('Invalid Configuration'),
text=_('Mimic cannot be used with port hopping.'),
parent=self,
)
- messageBox.open()
+ mbox.open()
return
diff --git a/Furious/Backends/Xray/AssetListView.py b/Furious/Backends/Xray/AssetListView.py
index 58d3bf7b..11fb69b2 100644
--- a/Furious/Backends/Xray/AssetListView.py
+++ b/Furious/Backends/Xray/AssetListView.py
@@ -51,7 +51,6 @@ class MBoxAssetExists(AppQMessageBox):
def retranslate(self):
"""Refresh translated text for the m box asset exists."""
- self.setWindowTitle(_(self.windowTitle()))
self.setText(_(self.text()))
# Ignore informative text, buttons
@@ -177,7 +176,6 @@ class XrayAssetListView(Mixins.ThemeAware, AppQListView):
# Any non-exit exception
_mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
- _mbox.setWindowTitle(_('Import'))
_mbox.setText(_('Error import asset file'))
_mbox.setInformativeText(str(ex))
@@ -187,7 +185,6 @@ class XrayAssetListView(Mixins.ThemeAware, AppQListView):
self.flushItem()
_mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Information)
- _mbox.setWindowTitle(_('Import'))
_mbox.setText(_('Import asset file success'))
# Show the MessageBox asynchronously
@@ -208,7 +205,6 @@ class XrayAssetListView(Mixins.ThemeAware, AppQListView):
pass
mbox = MBoxAssetExists(icon=AppQMessageBox.Icon.Question)
- mbox.setWindowTitle(_('Import'))
mbox.setText(_('Asset file already exists. Overwrite?'))
mbox.setInformativeText(basename)
mbox.finished.connect(functools.partial(handleResultCode, filename))
diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py
index 0c3b4fcb..e57b76a5 100644
--- a/Furious/Externals/GenTranslation.py
+++ b/Furious/Externals/GenTranslation.py
@@ -3,7 +3,6 @@ TRANSLATION = {
"source": [
"Furious.Backends.Xray.AssetWindow",
"Furious.Backends.Xray.RoutingWindow",
- "Furious.Qt.QtWidgets",
"Furious.Widget.ServerTableView",
"Furious.Widget.SubscriptionTableView",
"Furious.Window.SubscriptionPage"
@@ -104,8 +103,7 @@ TRANSLATION = {
},
"Import": {
"source": [
- "Furious.Actions.Import",
- "Furious.Backends.Xray.AssetListView"
+ "Furious.Actions.Import"
],
"RU": "Импорт",
"ZH": "导入",
@@ -162,14 +160,6 @@ TRANSLATION = {
"ZH": "文本文件 (*.json);;所有文件 (*)",
"isReviewed": "True"
},
- "Error opening file": {
- "source": [
- "Furious.Actions.Import"
- ],
- "RU": "Ошибка открытия файла",
- "ZH": "打开文件出错",
- "isReviewed": "True"
- },
"Invalid configuration file": {
"source": [
"Furious.Actions.Import"
@@ -234,14 +224,6 @@ TRANSLATION = {
"ZH": "取消",
"isReviewed": "True"
},
- "Save Changes": {
- "source": [
- "Furious.Window.TextEditorWindow"
- ],
- "RU": "Сохранить изменения",
- "ZH": "保存更改",
- "isReviewed": "True"
- },
"The content has been modified. Save changes?": {
"source": [
"Furious.Window.TextEditorWindow"
@@ -401,14 +383,6 @@ TRANSLATION = {
"ZH": "显示",
"isReviewed": "True"
},
- "Error saving configuration": {
- "source": [
- "Furious.Window.TextEditorWindow"
- ],
- "RU": "Ошибка сохранения конфигурации",
- "ZH": "保存配置出错",
- "isReviewed": "True"
- },
"Save File": {
"source": [
"Furious.Window.LogPage",
@@ -418,14 +392,6 @@ TRANSLATION = {
"ZH": "保存文件",
"isReviewed": "True"
},
- "Error Saving File": {
- "source": [
- "Furious.Window.TextEditorWindow"
- ],
- "RU": "Ошибка сохранения файла",
- "ZH": "保存文件出错",
- "isReviewed": "True"
- },
"Invalid server configuration": {
"source": [
"Furious.Controllers.ConnectionController",
@@ -435,14 +401,6 @@ TRANSLATION = {
"ZH": "无效的服务器配置",
"isReviewed": "True"
},
- "Error setting indent": {
- "source": [
- "Furious.Window.TextEditorWindow"
- ],
- "RU": "Ошибка при установке отступов",
- "ZH": "设置缩进出错",
- "isReviewed": "True"
- },
"Remark": {
"source": [
"Furious.Backends.ExternalCore.Editor",
@@ -642,7 +600,6 @@ TRANSLATION = {
"Connecting": {
"source": [
"Furious.Actions.Connection",
- "Furious.Widget.ServerTableView",
"Furious.Window.HomePage"
],
"RU": "Подключение",
@@ -714,14 +671,6 @@ TRANSLATION = {
"ZH": "文本文件 (*.txt);;所有文件 (*)",
"isReviewed": "True"
},
- "Error saving log": {
- "source": [
- "Furious.Window.LogPage"
- ],
- "RU": "Ошибка сохранения журнала",
- "ZH": "保存日志出错",
- "isReviewed": "True"
- },
"Asset file already exists. Overwrite?": {
"source": [
"Furious.Backends.Xray.AssetListView"
diff --git a/Furious/Qt/AGENTS.md b/Furious/Qt/AGENTS.md
index 80d7ef26..ceaad0b3 100644
--- a/Furious/Qt/AGENTS.md
+++ b/Furious/Qt/AGENTS.md
@@ -8,6 +8,10 @@ Use the `manage-qt-pyside6-lifetimes` skill for any Qt ownership or lifecycle ch
styling raw Qt controls at call sites.
- Construction-time source text should be retained by the control for retranslation. Do not duplicate manual
`retranslate()` code where an `AppQ*` widget/action/menu already supports it.
+- `AppQMessageBox.windowTitle()` is native window-manager/accessibility metadata and is not rendered by the frameless
+ Fluent surface. Put an optional visible semantic title in `heading`, the primary explanation in `text`, and supporting
+ details in `informativeText`. Never rely on `setWindowTitle()` alone for user-visible information, and do not promote a
+ generic application-name title into a visible heading.
- Preserve public exports and wildcard-import compatibility carefully; keep optional/heavy facilities lazily imported
where practical.
- `AppStyleSheet` remains the sole public stylesheet authority. Internal `StyleSheets` modules are data-oriented QSS
diff --git a/Furious/Qt/QtWidgets.py b/Furious/Qt/QtWidgets.py
index 6368d5d0..c63d889b 100644
--- a/Furious/Qt/QtWidgets.py
+++ b/Furious/Qt/QtWidgets.py
@@ -847,7 +847,11 @@ class _AppMessageBoxMask(QFrame):
class AppQMessageBox(AppQTransientDialog):
- """Present a responsive Fluent dialog with QMessageBox-compatible APIs."""
+ """Present a Fluent message box with separate metadata and visible layers.
+
+ ``title``/``windowTitle`` remain native window metadata for compatibility;
+ ``heading``, ``text``, and ``informativeText`` form the visible hierarchy.
+ """
Icon = QMessageBox.Icon
StandardButton = QMessageBox.StandardButton
@@ -897,11 +901,17 @@ class AppQMessageBox(AppQTransientDialog):
AppQMessageBox._openMessageBoxes.pop(key, None)
def __init__(self, *args, **kwargs):
- """Initialize the AppQMessageBox."""
- icon, parent, title, text, buttons = (
+ """Initialize the message box while preserving QMessageBox arguments."""
+ windowTitle = kwargs.pop('windowTitle', None)
+
+ icon, parent, title, heading, text, buttons = (
kwargs.pop('icon', self.Icon.NoIcon),
kwargs.pop('parent', None),
- kwargs.pop('title', ''),
+ kwargs.pop(
+ 'title',
+ APPLICATION_NAME if windowTitle is None else windowTitle,
+ ),
+ kwargs.pop('heading', ''),
kwargs.pop('text', ''),
kwargs.pop('buttons', self.StandardButton.NoButton),
)
@@ -921,6 +931,7 @@ class AppQMessageBox(AppQTransientDialog):
self._lifetimeKey = object()
self._windowMask = None
self._icon = self.Icon.NoIcon
+ self._heading = ''
self._text = ''
self._informativeText = ''
self._minimumContentWidth = 0
@@ -941,13 +952,14 @@ class AppQMessageBox(AppQTransientDialog):
self.destroyed.connect(release)
self.setObjectName('AppMessageBox')
+
windowFlags = (
QtCore.Qt.WindowType.Window
| QtCore.Qt.WindowType.FramelessWindowHint
| QtCore.Qt.WindowType.NoDropShadowWindowHint
)
-
self.setWindowFlags(windowFlags)
+
self.setAttribute(
QtCore.Qt.WidgetAttribute.WA_TranslucentBackground,
True,
@@ -986,10 +998,18 @@ class AppQMessageBox(AppQTransientDialog):
self.textWidget = QWidget()
self.textWidget.setObjectName('AppMessageBoxTextWidget')
- self.titleLabel = QLabel(self.textWidget)
- self.titleLabel.setObjectName('AppMessageBoxTitle')
- self.titleLabel.setWordWrap(True)
- self.titleLabel.setTextInteractionFlags(
+ self.headingLabel = QLabel(self.textWidget)
+ self.headingLabel.setObjectName('AppMessageBoxHeading')
+ self.headingLabel.setWordWrap(False)
+ self.headingLabel.setTextFormat(QtCore.Qt.TextFormat.PlainText)
+ self.headingLabel.setTextInteractionFlags(
+ QtCore.Qt.TextInteractionFlag.TextSelectableByMouse
+ )
+
+ self.textLabel = QLabel(self.textWidget)
+ self.textLabel.setObjectName('AppMessageBoxText')
+ self.textLabel.setWordWrap(True)
+ self.textLabel.setTextInteractionFlags(
QtCore.Qt.TextInteractionFlag.TextSelectableByMouse
)
@@ -1003,7 +1023,8 @@ class AppQMessageBox(AppQTransientDialog):
self.textLayout = QVBoxLayout(self.textWidget)
self.textLayout.setContentsMargins(0, 0, 0, 0)
self.textLayout.setSpacing(8)
- self.textLayout.addWidget(self.titleLabel)
+ self.textLayout.addWidget(self.headingLabel)
+ self.textLayout.addWidget(self.textLabel)
self.textLayout.addWidget(self.informativeLabel)
self.textViewport.setWidget(self.textWidget)
@@ -1036,6 +1057,7 @@ class AppQMessageBox(AppQTransientDialog):
self.dialogLayout.addWidget(self.surface)
self.setWindowTitle(title)
+ self.setHeading(heading)
self.setText(text)
self.setIcon(icon)
@@ -1198,10 +1220,24 @@ class AppQMessageBox(AppQTransientDialog):
if not self.buttons():
self.setStandardButtons(self.StandardButton.Ok)
+ def setHeading(self, heading):
+ """Set the optional visible semantic heading."""
+ self._heading = str(heading)
+ self.headingLabel.setText(self._heading)
+ self.headingLabel.setVisible(bool(self._heading))
+
+ if self.isVisible():
+ self._updateDialogSize()
+
+ def heading(self):
+ """Return the visible semantic heading."""
+ return self._heading
+
def setText(self, text):
"""Set the primary message text."""
self._text = str(text)
- self.titleLabel.setText(self._text)
+ self.textLabel.setText(self._text)
+ self.textLabel.setVisible(bool(self._text))
if self.isVisible():
self._updateDialogSize()
@@ -1484,6 +1520,23 @@ class AppQMessageBox(AppQTransientDialog):
return height
+ def _textContentHeight(self, width):
+ """Measure visible heading, primary, and supporting text blocks."""
+ blocks = (
+ (self.headingLabel, self._heading),
+ (self.textLabel, self._text),
+ (self.informativeLabel, self._informativeText),
+ )
+ heights = []
+
+ for label, value in blocks:
+ if value:
+ heights.append(self._wrappedHeight(label, width))
+ else:
+ label.setFixedHeight(0)
+
+ return sum(heights) + self.textLayout.spacing() * max(0, len(heights) - 1)
+
def _updateDialogSize(self):
"""Fit short content compactly and bound translated/long content."""
# QSS frame widths and button size hints participate in the geometry
@@ -1541,9 +1594,13 @@ class AppQMessageBox(AppQTransientDialog):
comfortableTextWidth = min(260, maximumTextWidth)
textWidths = [
- self.titleLabel.fontMetrics().horizontalAdvance(line)
- for line in self._text.splitlines() or ['']
+ self.headingLabel.fontMetrics().horizontalAdvance(line)
+ for line in self._heading.splitlines()
]
+ textWidths.extend(
+ self.textLabel.fontMetrics().horizontalAdvance(line)
+ for line in self._text.splitlines() or ['']
+ )
textWidths.extend(
self.informativeLabel.fontMetrics().horizontalAdvance(line)
for line in self._informativeText.splitlines()
@@ -1556,16 +1613,7 @@ class AppQMessageBox(AppQTransientDialog):
naturalTextWidth,
)
textWidth = min(maximumTextWidth, preferredTextWidth)
-
- titleHeight = self._wrappedHeight(self.titleLabel, textWidth)
- bodyHeight = 0
-
- if self._informativeText:
- bodyHeight = self._wrappedHeight(self.informativeLabel, textWidth) + 8
- else:
- self.informativeLabel.setFixedHeight(0)
-
- textHeight = titleHeight + bodyHeight
+ textHeight = self._textContentHeight(textWidth)
maximumTextHeight = max(
72,
min(
@@ -1583,13 +1631,7 @@ class AppQMessageBox(AppQTransientDialog):
self.textViewport,
)
textWidth = max(220, textWidth - scrollBarWidth)
- titleHeight = self._wrappedHeight(self.titleLabel, textWidth)
- bodyHeight = 0
-
- if self._informativeText:
- bodyHeight = self._wrappedHeight(self.informativeLabel, textWidth) + 8
-
- textHeight = titleHeight + bodyHeight
+ textHeight = self._textContentHeight(textWidth)
else:
scrollBarWidth = 0
@@ -1701,6 +1743,13 @@ class AppQMessageBox(AppQTransientDialog):
def retranslate(self):
"""Refresh translated text for the app q message box."""
self.setWindowTitle(_(self.windowTitle()))
+
+ try:
+ self.setHeading(_(self.heading()))
+ except KeyError:
+ # Runtime headings may already be localized by their owning layer.
+ pass
+
self.setText(_(self.text()))
try:
@@ -2114,7 +2163,6 @@ class MBoxQuestionDelete(AppQMessageBox):
self.isMulti = False
self.possibleRemark = ''
- self.setWindowTitle(_('Delete'))
self.setStandardButtons(
AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.No
)
@@ -2128,7 +2176,6 @@ class MBoxQuestionDelete(AppQMessageBox):
def retranslate(self):
"""Refresh translated text for the m box question delete."""
- self.setWindowTitle(_(self.windowTitle()))
self.setText(self.customText())
# Ignore informative text, buttons
@@ -2210,7 +2257,7 @@ class MBoxDirectRulesNotAllowed(AppQMessageBox):
"""Initialize the MBoxDirectRulesNotAllowed."""
super().__init__(*args, **kwargs)
- self.setWindowTitle(_('Unable to connect'))
+ self.setHeading(_('Unable to connect'))
self.setIcon(AppQMessageBox.Icon.Critical)
self.setStandardButtons(
AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.No
@@ -2228,7 +2275,7 @@ class MBoxDirectRulesNotAllowed(AppQMessageBox):
def retranslate(self):
"""Refresh translated text for the m box direct rules not allowed."""
- self.setWindowTitle(_(self.windowTitle()))
+ self.setHeading(_(self.heading()))
self.setText(self.customText())
# Ignore informative text, buttons
diff --git a/Furious/Qt/StyleSheets/Base.py b/Furious/Qt/StyleSheets/Base.py
index 7093b0ee..808cb201 100644
--- a/Furious/Qt/StyleSheets/Base.py
+++ b/Furious/Qt/StyleSheets/Base.py
@@ -91,7 +91,15 @@ def baseStyleSheet(palette):
background-color: transparent;
}}
- QLabel#AppMessageBoxTitle {{
+ QLabel#AppMessageBoxHeading {{
+ padding: 0;
+ border: none;
+ background-color: transparent;
+ color: {palette['text_strong']};
+ font-weight: 600;
+ }}
+
+ QLabel#AppMessageBoxText {{
padding: 0;
border: none;
background-color: transparent;
diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py
index 6427c3cf..1920d0f3 100644
--- a/Furious/Widget/ServerTableView.py
+++ b/Furious/Widget/ServerTableView.py
@@ -1575,7 +1575,6 @@ class ServerTableView(
if AppConnectionController().isConnecting():
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Information)
- mbox.setWindowTitle(_('Connecting'))
mbox.setText(_('Connecting. Please wait...'))
if PLATFORM != 'Darwin':
diff --git a/Furious/Window/LogPage.py b/Furious/Window/LogPage.py
index bcb9d69f..1814f0fd 100644
--- a/Furious/Window/LogPage.py
+++ b/Furious/Window/LogPage.py
@@ -89,7 +89,6 @@ class MBoxSaveError(AppQMessageBox):
def retranslate(self):
"""Refresh translated text for the m box save error."""
- self.setWindowTitle(_(self.windowTitle()))
self.setText(self.customText())
# Ignore informative text, buttons
@@ -112,7 +111,6 @@ def saveAsFile(content: str):
mbox = MBoxSaveError(icon=AppQMessageBox.Icon.Critical)
mbox.saveError = str(ex)
- mbox.setWindowTitle(_('Error saving log'))
mbox.setText(mbox.customText())
# Show the MessageBox asynchronously
diff --git a/Furious/Window/SettingsPage.py b/Furious/Window/SettingsPage.py
index f26fdcef..adabe116 100644
--- a/Furious/Window/SettingsPage.py
+++ b/Furious/Window/SettingsPage.py
@@ -76,19 +76,16 @@ def _endpointPrivacyParagraphs():
def _endpointPrivacyMessageBox(parent=None):
"""Build one transient Fluent data-usage disclosure."""
- title = _('Proxy Endpoint Information & Privacy')
-
- messageBox = AppQMessageBox(
+ mbox = AppQMessageBox(
icon=AppQMessageBox.Icon.Information,
parent=parent,
- title=title,
- text=title,
+ text=_('Proxy Endpoint Information & Privacy'),
buttons=AppQMessageBox.StandardButton.Ok,
)
- messageBox.informativeLabel.setTextFormat(QtCore.Qt.TextFormat.RichText)
- messageBox.setInformativeText('
\n'.join(_endpointPrivacyParagraphs()))
+ mbox.informativeLabel.setTextFormat(QtCore.Qt.TextFormat.RichText)
+ mbox.setInformativeText('
\n'.join(_endpointPrivacyParagraphs()))
- return messageBox
+ return mbox
def _tunModeTitle() -> str:
diff --git a/Furious/Window/SubscriptionPage.py b/Furious/Window/SubscriptionPage.py
index 1f2f59c0..b70ef122 100644
--- a/Furious/Window/SubscriptionPage.py
+++ b/Furious/Window/SubscriptionPage.py
@@ -191,13 +191,10 @@ class _SubscriptionEditorDialog(AppQTransientDialog):
def _showValidationError(self, message: str):
"""Show one non-blocking validation message owned by this dialog."""
- messageBox = AppQMessageBox(
- icon=AppQMessageBox.Icon.Warning,
- parent=self,
- )
- messageBox.setWindowTitle(_('Invalid data'))
- messageBox.setText(message)
- messageBox.open()
+ mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Warning, parent=self)
+ mbox.setHeading(_('Invalid data'))
+ mbox.setText(message)
+ mbox.open()
def accept(self):
"""Validate required fields before accepting the definition."""
@@ -381,18 +378,14 @@ class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
return
- messageBox = AppQMessageBox(
- icon=AppQMessageBox.Icon.Question,
- parent=self,
- )
- messageBox.setWindowTitle(_('Add Subscription'))
- messageBox.setText(_('Use the subscription URL from the clipboard?'))
- messageBox.setInformativeText(url)
- messageBox.setStandardButtons(
+ mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Question, parent=self)
+ mbox.setText(_('Use the subscription URL from the clipboard?'))
+ mbox.setInformativeText(url)
+ mbox.setStandardButtons(
AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.No
)
- messageBox.setDefaultButton(AppQMessageBox.StandardButton.Yes)
- messageBox.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
+ mbox.setDefaultButton(AppQMessageBox.StandardButton.Yes)
+ mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
def finished(code):
"""Continue with clipboard assistance or the regular workflow."""
@@ -403,8 +396,8 @@ class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
else:
self._openEditor()
- messageBox.finished.connect(finished)
- messageBox.open()
+ mbox.finished.connect(finished)
+ mbox.open()
def addFromClipboard(self, clipboardURL=None):
"""Seed a subscription from an HTTP(S) URL on the clipboard."""
diff --git a/Furious/Window/TextEditorWindow.py b/Furious/Window/TextEditorWindow.py
index 80b54fed..9f2c11dd 100644
--- a/Furious/Window/TextEditorWindow.py
+++ b/Furious/Window/TextEditorWindow.py
@@ -45,7 +45,6 @@ class MBoxQuestionSave(AppQMessageBox):
"""Initialize the MBoxQuestionSave."""
super().__init__(*args, **kwargs)
- self.setWindowTitle(_('Save Changes'))
self.setText(_('The content has been modified. Save changes?'))
self.button0 = self.addButton(_('Save'), AppQMessageBox.ButtonRole.AcceptRole)
@@ -75,7 +74,6 @@ class MBoxJSONDecodeError(AppQMessageBox):
def retranslate(self):
"""Refresh translated text for the m box JSON decode error."""
- self.setWindowTitle(_(self.windowTitle()))
self.setText(self.customText())
# Ignore informative text, buttons
@@ -313,7 +311,6 @@ class TextEditorWindow(AppQMainWindow):
mbox = MBoxJSONDecodeError(icon=AppQMessageBox.Icon.Critical, parent=self)
mbox.error = str(ex)
- mbox.setWindowTitle(_('Error saving configuration'))
mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
mbox.setText(mbox.customText())
@@ -358,7 +355,6 @@ class TextEditorWindow(AppQMainWindow):
# Any non-exit exceptions
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical, parent=self)
- mbox.setWindowTitle(_('Error Saving File'))
mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
mbox.setText(_('Invalid server configuration'))
mbox.setInformativeText(str(ex))
@@ -394,7 +390,6 @@ class TextEditorWindow(AppQMainWindow):
mbox = MBoxJSONDecodeError(icon=AppQMessageBox.Icon.Critical, parent=self)
mbox.error = str(ex)
- mbox.setWindowTitle(_('Error setting indent'))
mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
mbox.setText(mbox.customText())
diff --git a/tests/test_qt_lifetime.py b/tests/test_qt_lifetime.py
index b185ad01..220d7923 100644
--- a/tests/test_qt_lifetime.py
+++ b/tests/test_qt_lifetime.py
@@ -227,6 +227,7 @@ class QtLifetimeTest(unittest.TestCase):
messageBox = AppQMessageBox(
icon=AppQMessageBox.Icon.Information,
parent=owner,
+ heading='Fixture heading',
text='Fixture information',
buttons=AppQMessageBox.StandardButton.Ok,
)
diff --git a/tests/test_ui_behavior.py b/tests/test_ui_behavior.py
index e0a7669d..b4ab4cdc 100644
--- a/tests/test_ui_behavior.py
+++ b/tests/test_ui_behavior.py
@@ -35,6 +35,7 @@ from Furious.Backends.Xray.VlessEditor import VlessEditor
from Furious.Backends.Xray.VmessEditor import VmessEditor
from Furious.Actions.Connection import ConnectAction
from Furious.Controllers.ConnectionController import (
+ ConnectionError,
ConnectionController,
ConnectionState,
)
@@ -42,9 +43,15 @@ from Furious.Controllers.SettingsController import (
LOG_AUTO_CLEAR_SETTING,
LOG_AUTO_SCROLL_DOWN_SETTING,
)
-from Furious.Frozenlib import AppSettings, Mixins
+from Furious.Frozenlib import APPLICATION_NAME, AppSettings, Mixins
from Furious.Models import ProfileMetadata, ServerProfile
-from Furious.Qt import AppHue, AppQMessageBox, AppQSwitch, gettext as _
+from Furious.Qt import (
+ AppHue,
+ AppQMessageBox,
+ AppQSwitch,
+ AppStyleSheet,
+ gettext as _,
+)
from Furious.Service import (
APPLICATION_LOG_CATEGORY,
CORE_LOG_CATEGORY,
@@ -816,6 +823,136 @@ class DialogBehaviorTest(unittest.TestCase):
self.assertEqual(finished, [int(AppQMessageBox.StandardButton.Yes)])
self.assertIs(messageBox.clickedButton(), yesButton)
+ def testMessageBoxSeparatesNativeTitleHeadingAndBody(self):
+ """Keep native metadata independent from visible semantic content."""
+ messageBox = AppQMessageBox(
+ title='Native window metadata',
+ heading='Unable to connect',
+ text='Xray-core: Invalid server configuration',
+ )
+ messageBox.setInformativeText('The outbound server address is missing.')
+ messageBox.show()
+
+ processQtEvents()
+
+ self.assertEqual(messageBox.windowTitle(), 'Native window metadata')
+ self.assertEqual(messageBox.heading(), 'Unable to connect')
+ self.assertEqual(messageBox.text(), 'Xray-core: Invalid server configuration')
+ self.assertEqual(
+ messageBox.informativeText(),
+ 'The outbound server address is missing.',
+ )
+ self.assertEqual(messageBox.headingLabel.text(), 'Unable to connect')
+ self.assertEqual(
+ messageBox.textLabel.text(),
+ 'Xray-core: Invalid server configuration',
+ )
+ self.assertFalse(messageBox.headingLabel.isHidden())
+
+ messageBox.setWindowTitle('Changed native metadata')
+
+ self.assertEqual(messageBox.heading(), 'Unable to connect')
+
+ messageBox.close()
+
+ def testMessageBoxKeepsGenericApplicationTitleAsMetadataOnly(self):
+ """Do not promote the default application name into visible content."""
+ messageBox = AppQMessageBox(text='Operation completed')
+ messageBox.show()
+
+ processQtEvents()
+
+ self.assertEqual(messageBox.windowTitle(), APPLICATION_NAME)
+ self.assertEqual(messageBox.heading(), '')
+ self.assertTrue(messageBox.headingLabel.isHidden())
+ self.assertEqual(messageBox.textLabel.y(), 0)
+
+ positional = AppQMessageBox(
+ AppQMessageBox.Icon.Information,
+ 'Native compatibility title',
+ 'Compatibility body',
+ AppQMessageBox.StandardButton.Ok,
+ )
+
+ self.assertEqual(positional.windowTitle(), 'Native compatibility title')
+ self.assertEqual(positional.heading(), '')
+ self.assertEqual(positional.text(), 'Compatibility body')
+
+ messageBox.close()
+ positional.deleteLater()
+
+ def testConnectionErrorPreservesHeadingMessageAndDetails(self):
+ """Map every structured connection-error field to visible dialog content."""
+ error = ConnectionError(
+ 'Unable to connect',
+ 'Xray-core: Invalid server configuration',
+ 'The outbound server address is missing.',
+ )
+ opened = []
+
+ with mock.patch.object(
+ AppQMessageBox,
+ 'open',
+ lambda messageBox: opened.append(messageBox),
+ ):
+ ConnectAction.showError(error)
+
+ self.assertEqual(len(opened), 1)
+
+ messageBox = opened[0]
+
+ self.assertEqual(messageBox.windowTitle(), APPLICATION_NAME)
+ self.assertEqual(messageBox.heading(), error.title)
+ self.assertEqual(messageBox.text(), error.message)
+ self.assertEqual(messageBox.informativeText(), error.details)
+
+ messageBox.close()
+ messageBox.deleteLater()
+
+ def testMessageBoxHeadingSupportsLongContentIconsAndThemes(self):
+ """Keep the semantic stack responsive across themes and icon variants."""
+ app = application()
+ originalStyleSheet = app.styleSheet()
+ longMessage = (
+ 'Не удалось применить конфигурацию прокси-сервера. '
+ 'Проверьте адрес, порт и параметры подключения, затем повторите попытку.'
+ )
+
+ try:
+ for theme in (AppStyleSheet.Light, AppStyleSheet.Dark):
+ app.setStyleSheet(AppStyleSheet.forTheme(theme))
+
+ for icon in (
+ AppQMessageBox.Icon.Information,
+ AppQMessageBox.Icon.Warning,
+ AppQMessageBox.Icon.Critical,
+ AppQMessageBox.Icon.Question,
+ ):
+ with self.subTest(theme=theme, icon=icon):
+ messageBox = AppQMessageBox(
+ icon=icon,
+ heading='Unable to connect',
+ text=longMessage,
+ )
+ messageBox.show()
+
+ processQtEvents()
+
+ self.assertFalse(messageBox.iconPixmap().isNull())
+ self.assertGreaterEqual(
+ messageBox.textLabel.height(),
+ messageBox.textLabel.fontMetrics().height(),
+ )
+ self.assertLessEqual(
+ messageBox.width(),
+ messageBox.MaximumSurfaceWidth,
+ )
+
+ messageBox.close()
+ messageBox.deleteLater()
+ finally:
+ app.setStyleSheet(originalStyleSheet)
+
def testMessageBoxButtonsHaveAdaptiveFluentLayoutAndRoles(self):
"""Keep one, two, and three actions slim, separated, and content-driven."""
configurations = (