From 55869b6087c6e50b8e696759fcfd10b6d8477386 Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Fri, 21 Aug 2026 15:13:16 +0800 Subject: [PATCH] Refine Fluent message box content hierarchy Signed-off-by: Loren Eteval --- Furious/Actions/AGENTS.md | 2 + Furious/Actions/Connection.py | 2 +- Furious/Actions/Import.py | 8 -- Furious/Backends/ExternalCore/Editor.py | 8 +- Furious/Backends/Hysteria2/Editor.py | 6 +- Furious/Backends/Xray/AssetListView.py | 4 - Furious/Externals/GenTranslation.py | 53 +-------- Furious/Qt/AGENTS.md | 4 + Furious/Qt/QtWidgets.py | 115 +++++++++++++------ Furious/Qt/StyleSheets/Base.py | 10 +- Furious/Widget/ServerTableView.py | 1 - Furious/Window/LogPage.py | 2 - Furious/Window/SettingsPage.py | 13 +-- Furious/Window/SubscriptionPage.py | 31 ++---- Furious/Window/TextEditorWindow.py | 5 - tests/test_qt_lifetime.py | 1 + tests/test_ui_behavior.py | 141 +++++++++++++++++++++++- 17 files changed, 262 insertions(+), 144 deletions(-) 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 = (