Improve widget retranslation

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-10-07 09:47:38 +08:00
parent 58b36b63f3
commit 08970ffd7e
14 changed files with 282 additions and 83 deletions
+5 -17
View File
@@ -304,8 +304,8 @@ class ImportURIsProgressDialog(AppQTransientDialog):
def retranslate(self):
"""Refresh translated text for the import ur is progress dialog."""
self.setWindowTitle(_(self.windowTitle()))
self.cancelButton.setText(_(self.cancelButton.text()))
super().retranslate()
self.updateStatus()
@@ -316,14 +316,10 @@ class MBoxImportError(AppQMessageBox):
"""Initialize the MBoxImportError."""
super().__init__(*args, **kwargs)
def retranslate(self):
def _retranslateContent(self):
"""Refresh translated text for the m box import error."""
self.setText(_(self.text()))
# Ignore informative text, buttons
self.moveToCenter()
class MBoxImportMultiSuccess(AppQMessageBox):
"""Represent m box import multi success."""
@@ -358,14 +354,10 @@ class MBoxImportMultiSuccess(AppQMessageBox):
# Limited
return _('Import share link success') + f'\n\n...'
def retranslate(self):
def _retranslateContent(self):
"""Refresh translated text for the m box import multi success."""
self.setText(self.customText())
# Ignore informative text, buttons
self.moveToCenter()
class MBoxImportSuccess(AppQMessageBox):
"""Represent m box import success."""
@@ -383,14 +375,10 @@ class MBoxImportSuccess(AppQMessageBox):
else:
return _('Import success')
def retranslate(self):
def _retranslateContent(self):
"""Refresh translated text for the m box import success."""
self.setText(self.customText())
# Ignore informative text, buttons
self.moveToCenter()
class ImportFromFileAction(AppQAction):
"""Handle the import from file action."""
+1 -5
View File
@@ -49,14 +49,10 @@ class MBoxAssetExists(AppQMessageBox):
AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.No
)
def retranslate(self):
def _retranslateContent(self):
"""Refresh translated text for the m box asset exists."""
self.setText(_(self.text()))
# Ignore informative text, buttons
self.moveToCenter()
class XrayAssetListView(Mixins.ThemeAware, AppQListView):
"""Provide the model-based Xray asset viewer list."""
+8
View File
@@ -15,6 +15,14 @@ behavior, and lifetime primitives; pages and services consume them without creat
object-name rule even when the widget's enabled state is correct.
- Controls that retranslate retain source text; semantic/user-defined values stay untranslated. Preserve keyboard focus,
shortcut scope, accessibility, translated-text growth, responsive layout, high-DPI behavior, and both themes.
Extend a concrete base retranslator when its standard fields still apply; retain intentional state/content
overrides instead of calling the abstract mixin. Registered child controls translate themselves, including their
opt-out flags; owners translate plain Qt controls that have no registration. Message boxes customize
`_retranslateContent()` for composite text or preserved diagnostics, leaving title, heading, buttons and geometry
with the shared retranslator. The locale-switch cases in `tests/test_ui_behavior.py` cover both button policies
and diagnostic preservation; progress and metrics cases cover one child refresh per global pass.
Visible message boxes refit after registered children finish translating, through a weak continuation that
ignores hidden or destroyed dialogs. Preserve their bounded button widths and transient destruction policy.
- Application-owned theme transitions commit destination state immediately; snapshots are non-interactive presentation
objects that are interrupted/disposed on replacement, geometry change, window destruction, or application cleanup.
Geometry/destruction event filters retire snapshots immediately but defer completion observers through an owned
+9 -3
View File
@@ -304,12 +304,18 @@ class AppQAction(Mixins.QTranslatable, Mixins.ThemeAware, QAction):
def recursiveTranslate(action, memo):
"""Handle recursive translate for the app q action."""
if action not in memo and not action.isSeparator() and action.translatable:
action.setText(_(action.text()))
action.setStatusTip(_(action.statusTip()))
if action in memo:
return
memo[action] = True
if action is not self and isinstance(action, Mixins.QTranslatable):
return
if not action.isSeparator() and getattr(action, 'translatable', True):
action.setText(_(action.text()))
action.setStatusTip(_(action.statusTip()))
# Some old version PySide6 does not have menu() method
# for QAction. Protect it
if hasattr(action, 'menu'):
+33 -28
View File
@@ -522,7 +522,9 @@ class AppQDialogButtonBox(Mixins.QTranslatable, QDialogButtonBox):
def retranslate(self):
"""Refresh translated text for the app Qt dialog button box."""
for button in self.buttons():
button.setText(_(button.text()))
if not isinstance(button, Mixins.QTranslatable):
# Manage the translation explicitly
button.setText(_(button.text()))
class AppQGroupBox(Mixins.QTranslatable, QGroupBox):
@@ -1995,7 +1997,7 @@ class AppQMessageBox(AppQTransientDialog):
def retranslate(self):
"""Refresh translated text for the app q message box."""
self.setWindowTitle(_(self.windowTitle()))
super().retranslate()
try:
self.setHeading(_(self.heading()))
@@ -2003,6 +2005,32 @@ class AppQMessageBox(AppQTransientDialog):
# Runtime headings may already be localized by their owning layer.
pass
self._retranslateContent()
for button in self.buttons():
if isinstance(button, Mixins.QTranslatable):
continue
if button.text().find('OK') != -1:
# &OK...
pass
else:
button.setText(_(button.text()))
if self.isVisible():
# Registered child buttons finish translating later in the global pass.
singleShotWeakly(0, self, '_refreshTranslatedGeometry')
else:
self.moveToCenter()
def _refreshTranslatedGeometry(self):
"""Fit current translated controls after the registered children update."""
if self.isVisible():
self._updateDialogSize()
self.moveToCenter()
def _retranslateContent(self):
"""Translate ordinary content; subclasses own composite or diagnostic text."""
self.setText(_(self.text()))
try:
@@ -2011,15 +2039,6 @@ class AppQMessageBox(AppQTransientDialog):
# Any translatable informative text
pass
for button in self.buttons():
if button.text().find('OK') != -1:
# &OK...
pass
else:
button.setText(_(button.text()))
self.moveToCenter()
def disconnectedCallback(self):
"""Update the app q message box for a disconnected state."""
self.setWindowIcon(AppHue.disconnectedWindowIcon())
@@ -2466,14 +2485,10 @@ class MBoxQuestionDelete(AppQMessageBox):
else:
return _('Delete this item?') + f'\n\n{self.possibleRemark}'
def retranslate(self):
def _retranslateContent(self):
"""Refresh translated text for the m box question delete."""
self.setText(self.customText())
# Ignore informative text, buttons
self.moveToCenter()
class MBoxNewChangesNextTime(AppQMessageBox):
"""Represent m box new changes next time."""
@@ -2496,15 +2511,10 @@ class MBoxNewChangesNextTime(AppQMessageBox):
_('New changes will take effect next time') + '\n\n' + _('Reconnect now?')
)
def retranslate(self):
def _retranslateContent(self):
"""Refresh translated text for the m box new changes next time."""
self.setWindowTitle(_(self.windowTitle()))
self.setText(self.customText())
# Ignore informative text, buttons
self.moveToCenter()
def showMBoxNewChangesNextTime(**kwargs):
"""Show m box new changes next time."""
@@ -2565,15 +2575,10 @@ class MBoxDirectRulesNotAllowed(AppQMessageBox):
+ _('Switch to global and reconnect?')
)
def retranslate(self):
def _retranslateContent(self):
"""Refresh translated text for the m box direct rules not allowed."""
self.setHeading(_(self.heading()))
self.setText(self.customText())
# Ignore informative text, buttons
self.moveToCenter()
def showMBoxDirectRulesNotAllowed(**kwargs):
"""Show m box direct rules not allowed."""
+1 -6
View File
@@ -54,16 +54,11 @@ class MBoxQuestionUpdate(AppQMessageBox):
"""Return the user-facing message text for the m box question update."""
return _('New version available') + f': {self.version}'
def retranslate(self):
def _retranslateContent(self):
"""Refresh translated text for the m box question update."""
self.setText(self.customText())
self.setWindowTitle(_(self.windowTitle()))
self.setInformativeText(_(self.informativeText()))
# Ignore button text
self.moveToCenter()
class UpdateManager(HttpGetManager):
"""Coordinate updates operations."""
+5 -8
View File
@@ -122,16 +122,11 @@ class MBoxUpdateSubsInfo(AppQMessageBox):
* self.fontMetrics().averageCharWidth(),
)
def retranslate(self):
def _retranslateContent(self):
"""Refresh translated text for the m box update subs info."""
self.setWindowTitle(_(self.windowTitle()))
self.setText(self.customText())
self.setColumnMinWidth()
# Ignore informative text, buttons
self.moveToCenter()
class DeleteServersProgressDialog(AppQTransientDialog):
"""Present progress and cancellation controls for delete servers."""
@@ -279,8 +274,8 @@ class DeleteServersProgressDialog(AppQTransientDialog):
def retranslate(self):
"""Refresh translated text for the delete servers progress dialog."""
self.setWindowTitle(_(self.windowTitle()))
self.cancelButton.setText(_(self.cancelButton.text()))
super().retranslate()
self.updateStatus()
@@ -320,6 +315,7 @@ class DuplicateServersProgressDialog(AppQTransientDialog):
def open(self):
result = super().open()
singleShotWeakly(0, self, 'duplicateNext')
return result
@@ -1501,6 +1497,7 @@ class ServerTableView(
for row, profile in enumerate(Storage.UserServers())
if profile.metadata.profileId in changed
]
self.sourceModel.dataChanged.emit(
self.sourceModel.index(rows[0], 0),
self.sourceModel.index(rows[-1], self.sourceModel.columnCount() - 1),
+1 -5
View File
@@ -131,14 +131,10 @@ class MBoxSaveError(AppQMessageBox):
else:
return _('Unable to save log')
def retranslate(self):
def _retranslateContent(self):
"""Refresh translated text for the m box save error."""
self.setText(self.customText())
# Ignore informative text, buttons
self.moveToCenter()
def saveAsFile(content: str):
"""Save as file."""
-2
View File
@@ -458,7 +458,5 @@ class MetricsPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
_('Upload Speed'),
_('Upload Traffic Usage'),
)
self.endpointInfoWidget.retranslate()
self._dirty = True
self._scheduleRender()
+1 -5
View File
@@ -72,14 +72,10 @@ class MBoxJSONDecodeError(AppQMessageBox):
+ f'\n\n{self.error}'
)
def retranslate(self):
def _retranslateContent(self):
"""Refresh translated text for the m box JSON decode error."""
self.setText(self.customText())
# Ignore informative text, buttons
self.moveToCenter()
class TextEditorWindow(AppQMainWindow):
"""Present the text editor window."""
+2 -2
View File
@@ -102,7 +102,7 @@ worker. Choose tests by the changed contract rather than by filename alone.
| [test_service_runtime.py](test_service_runtime.py) | Update-response validation, HTTP timeouts/context release/reentrant destruction, duplicate completion, plugin-page registration, bounded connectivity requests, blocked statistics-worker callback lifetime. |
| [test_xray_asset_download.py](test_xray_asset_download.py) | Checksum validation, atomic asset replacement, real pool delivery, early owner destruction, callback release, plugin shutdown. |
| [test_endpoint_info.py](test_endpoint_info.py) | Opt-in proxy-only discovery using fake HTTP responses, fallback/cache/session invalidation, privacy/presentation controls, local map styling and persistent-scene contracts. |
| [test_metrics_behavior.py](test_metrics_behavior.py) | Immutable time-series history, pruning/buckets, usage versus speed clearing, hidden-page rendering, graph hover lookup. |
| [test_metrics_behavior.py](test_metrics_behavior.py) | Immutable time-series history, pruning/buckets, usage versus speed clearing, hidden-page rendering, graph hover lookup, and independent endpoint retranslation without changing history. |
| [test_log_manager_generation.py](test_log_manager_generation.py) | Reference-model state transitions, concurrency, live/retired ownership, bounded FIFO cleanup, structural complexity, incremental/multiline LogPage rendering; also contains an opt-in generation campaign. |
| [test_log_manager_benchmark.py](test_log_manager_benchmark.py) | Data-only report validation, workload compatibility, timing ratios, and real standalone CLI JSON round trips; no performance gates or historical imports. |
@@ -114,7 +114,7 @@ worker. Choose tests by the changed contract rather than by filename alone.
| [test_main_window_geometry.py](test_main_window_geometry.py) | First-show lifecycle, restored/default/legacy geometry, main/routing window reuse, session-only navigation state, protection of never-shown windows' saved geometry. |
| [test_dialog_geometry.py](test_dialog_geometry.py) | Dialog show/open/exec preparation, sizing/centering, failure cleanup, specialized message-box geometry and transient destruction. |
| [test_layout_matrix.py](test_layout_matrix.py) | Navigation and message-box layouts in fresh processes at scale factors 1, 1.25, 1.5, and 2, under both themes. |
| [test_ui_behavior.py](test_ui_behavior.py) | Translation and editor mappings, settings construction visibility/organization/search/platform gates, stable server/routing moves, real QR rendering/decoding, incremental log filtering/tail behavior, connected-routing change notices, message boxes and connection-error copy/log navigation, shared Home/tray state. |
| [test_ui_behavior.py](test_ui_behavior.py) | Translation and editor mappings, settings construction visibility/organization/search/platform gates, stable server/routing moves, real QR rendering/decoding, incremental log filtering/tail behavior, connected-routing change notices, message boxes and connection-error copy/log navigation, shared Home/tray state, preserved message payloads and managed-versus-plain button retranslation. |
| [test_qt_interactions.py](test_qt_interactions.py) | Real keyboard/mouse/focus and proxy mapping, scoped shortcuts, sorting/selection, stable subscription deletion confirmations, debounced Home search, favorite actions and intersecting filters, Tests-menu selection color, shared settings, batched import/delete/duplication, cancellation, preparation failure, and owner destruction. |
| [test_qr_export_scalability.py](test_qr_export_scalability.py) | Production capture cap, immediate single export, incremental yielding, failure/cancel/close paths, immutable snapshots, window-owned state destruction. |
| [test_stylesheet_states.py](test_stylesheet_states.py) | Targeted rendering/alpha/geometry assertions for table/list insets, popup corners, clear buttons, focus/disabled states, and stylesheet composition. |
+43 -1
View File
@@ -19,6 +19,7 @@
from __future__ import annotations
from Furious.Frozenlib import AppSettings, Mixins
from Furious.Qt import gettext as _
from Furious.Service.MetricsHistory import (
DOWNLOAD_SPEED_METRIC,
@@ -33,7 +34,15 @@ from Furious.Window.MetricsPage import MetricsPage
from PySide6 import QtCore
from tests.support import application, collectAtBoundary, processQtEvents, waitFor
from tests.support import (
application,
collectAtBoundary,
isolatedSettings,
processQtEvents,
waitFor,
)
from unittest import mock
import math
import time
@@ -190,6 +199,39 @@ class MetricsPageAndGraphTest(unittest.TestCase):
"""Drain deferred widget destruction after every UI case."""
collectAtBoundary()
def testGlobalRetranslationRefreshesEndpointOnceWithoutChangingHistory(self):
with isolatedSettings():
AppSettings.set('Language', 'EN')
manager = MetricsHistory()
page = MetricsPage(manager)
manager.recordSample({DOWNLOAD_SPEED_METRIC: 32})
samples = manager.rawSamples()
try:
for locale in ('RU', 'ZH', 'EN'):
AppSettings.set('Language', locale)
with mock.patch.object(
page.endpointInfoWidget,
'retranslate',
wraps=page.endpointInfoWidget.retranslate,
) as translate:
Mixins.QTranslatable.retranslateAll()
self.assertEqual(translate.call_count, 1)
self.assertEqual(
page.metricsCard.downloadTitleLabel.text(), _('Download')
)
self.assertEqual(
page.endpointInfoWidget.titleLabel.text(),
_('Proxy Endpoint Information'),
)
self.assertEqual(manager.rawSamples(), samples)
self.assertEqual(page._renderRevision, 0)
finally:
page.close()
page.deleteLater()
manager.deleteLater()
processQtEvents()
def testTrafficGraphsAndSelectorsShareOneCard(self):
"""Keep the page title separate while grouping all metrics controls."""
manager = MetricsHistory()
+53
View File
@@ -2343,6 +2343,59 @@ class ProfileMutationBatchTest(unittest.TestCase):
self.assertFalse(isValid(dialog))
self.assertEqual(len(Storage.UserServers()), 1000)
def testProgressDialogsUseSharedRetranslationWithoutLosingProgress(self):
with self.table(10) as (table, controller):
AppSettings.set('Language', 'EN')
dialogs = (
(
ImportURIsProgressDialog(('fixture',) * 10, parent=table),
'Import',
'Importing',
'currentIndex',
),
(
DeleteServersProgressDialog(table, range(10), parent=table),
'Delete',
'Deleting',
'deletedCount',
),
(
DuplicateServersProgressDialog(table, table._visibleProfileIds()),
'Duplicate',
'Duplicating',
'copiedCount',
),
)
for dialog, title, status, counter in dialogs:
setattr(dialog, counter, 7)
for locale in ('RU', 'ZH', 'EN'):
AppSettings.set('Language', locale)
with ExitStack() as stack:
translations = [
stack.enter_context(
mock.patch.object(
dialog.cancelButton,
'retranslate',
wraps=dialog.cancelButton.retranslate,
)
)
for dialog, *_fields in dialogs
]
Mixins.QTranslatable.retranslateAll()
for (dialog, title, status, counter), translated in zip(
dialogs, translations
):
self.assertEqual(translated.call_count, 1)
self.assertEqual(dialog.windowTitle(), gettext(title))
self.assertEqual(dialog.cancelButton.text(), gettext('Cancel'))
self.assertEqual(
dialog.statusLabel.text(), gettext(status) + '... 7/10'
)
self.assertEqual(getattr(dialog, counter), 7)
def testImportCoalescesRowsAndProgressWithoutLosingInvalidInputPositions(self):
profiles = [self.profile(str(index)) for index in range(600)]
invalid = mock.Mock()
+120 -1
View File
@@ -66,6 +66,7 @@ from Furious.Backends.Xray.VmessEditor import (
VmessEditor,
)
from Furious.Actions.Connection import ConnectAction, ConnectionErrorMessageBox
from Furious.Actions.Import import MBoxImportError
from Furious.Controllers.ConnectionController import (
ConnectionError,
ConnectionController,
@@ -99,9 +100,13 @@ from Furious.Repository import Storage
from Furious.Repository.Routings import UserRoutings
from Furious.Qt import (
AppHue,
AppQAction,
AppQComboBox,
AppQDialog,
AppQDialogButtonBox,
AppQMessageBox,
AppQMenu,
AppQPushButton,
AppQSwitch,
AppStyleSheet,
gettext as _,
@@ -130,9 +135,10 @@ from Furious.Widget.RoutingSelector import RoutingSelector
from Furious.Widget.ServerTableView import ServerTableView
from PySide6 import QtCore
from PySide6.QtGui import QImage
from PySide6.QtGui import QAction, QImage
from PySide6.QtTest import QAbstractItemModelTester, QSignalSpy, QTest
from PySide6.QtWidgets import (
QPushButton,
QStackedWidget,
QToolButton,
QComboBox,
@@ -2972,6 +2978,119 @@ class DialogBehaviorTest(unittest.TestCase):
"""Finish every deferred transient deletion between tests."""
collectAtBoundary()
def testCustomMessageRetranslationKeepsPayloadAndUpdatesDialogControls(self):
with isolatedSettings():
AppSettings.set('Language', 'EN')
box = MBoxImportError()
box.setWindowTitle(_('Duplicate'))
box.setHeading(_('Delete'))
box.setText(_('Invalid data'))
box.setInformativeText('Save')
button = box.addButton('Cancel', box.ButtonRole.RejectRole)
try:
for locale in ('RU', 'ZH', 'EN'):
AppSettings.set('Language', locale)
Mixins.QTranslatable.retranslateAll()
self.assertEqual(box.windowTitle(), _('Duplicate'))
self.assertEqual(box.heading(), _('Delete'))
self.assertEqual(box.text(), _('Invalid data'))
self.assertEqual(box.informativeText(), 'Save')
self.assertEqual(button.text(), _('Cancel'))
finally:
box.deleteLater()
processQtEvents()
def testButtonContainersRespectManagedTranslationAndPlainQtFallback(self):
with isolatedSettings():
AppSettings.set('Language', 'EN')
for containerType in (AppQMessageBox, AppQDialogButtonBox):
with self.subTest(container=containerType.__name__):
owner = containerType()
protected = AppQPushButton('Cancel', translatable=False)
managed = AppQPushButton('Save')
plain = QPushButton('Cancel')
for button in (protected, managed, plain):
owner.addButton(button, owner.ButtonRole.ActionRole)
try:
for locale in ('RU', 'ZH', 'EN'):
AppSettings.set('Language', locale)
with mock.patch.object(
managed, 'retranslate', wraps=managed.retranslate
) as translate:
Mixins.QTranslatable.retranslateAll()
self.assertEqual(translate.call_count, 1)
self.assertEqual(protected.text(), 'Cancel')
self.assertEqual(managed.text(), _('Save'))
self.assertEqual(plain.text(), _('Cancel'))
finally:
owner.deleteLater()
processQtEvents()
def testActionRetranslationLeavesManagedChildrenToTheirOwnPass(self):
with isolatedSettings():
AppSettings.set('Language', 'EN')
owner = QWidget()
menu = AppQMenu(parent=owner)
managed = AppQAction('Save', parent=menu)
protected = AppQAction('Cancel', parent=menu, translatable=False)
menu.addActions([managed, protected])
parent = AppQAction('Server', parent=owner, menu=menu)
try:
for locale in ('RU', 'ZH', 'EN'):
AppSettings.set('Language', locale)
with mock.patch.object(
managed, 'setText', wraps=managed.setText
) as update:
Mixins.QTranslatable.retranslateAll()
self.assertEqual(update.call_count, 1)
self.assertEqual(parent.text(), _('Server'))
self.assertEqual(managed.text(), _('Save'))
self.assertEqual(protected.text(), 'Cancel')
plain = QAction('Cancel', menu)
menu.addAction(plain)
AppSettings.set('Language', 'ZH')
Mixins.QTranslatable.retranslateAll()
self.assertEqual(plain.text(), _('Cancel'))
finally:
owner.deleteLater()
processQtEvents()
def testMessageGeometryRefreshesAfterManagedButtonTranslation(self):
with isolatedSettings():
AppSettings.set('Language', 'EN')
box = AppQMessageBox()
button = AppQPushButton(_('Copy Error'))
box.addButton(button, box.ButtonRole.ActionRole)
box.open()
processQtEvents()
try:
for locale in ('RU', 'ZH', 'EN'):
AppSettings.set('Language', locale)
Mixins.QTranslatable.retranslateAll()
processQtEvents()
self.assertEqual(button.text(), _('Copy Error'))
self.assertGreaterEqual(
button.width(), box._preferredButtonWidth(button)
)
self.assertEqual(box.surface.size(), box.size())
AppSettings.set('Language', 'RU')
Mixins.QTranslatable.retranslateAll()
deleteQObject(box)
processQtEvents()
self.assertFalse(isValid(box))
finally:
if isValid(box):
box.close()
processQtEvents()
def testThemeFallbackWorksBeforeConnectionControllerExists(self):
"""Allow startup error dialogs to use the disconnected theme safely."""
with mock.patch(