diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index fc77195..37e9056 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -1606,7 +1606,8 @@ TRANSLATION = { "Search": { "source": [ "Furious.Window.HomePage", - "Furious.Window.LogPage" + "Furious.Window.LogPage", + "Furious.Window.SettingsPage" ], "RU": "Поиск", "ZH": "查找", @@ -3644,5 +3645,21 @@ TRANSLATION = { "RU": "Открыть журнал", "ZH": "打开日志", "isReviewed": "True" + }, + "Search settings...": { + "source": [ + "Furious.Window.SettingsPage" + ], + "RU": "Поиск настроек...", + "ZH": "搜索设置...", + "isReviewed": "True" + }, + "No settings match your search.": { + "source": [ + "Furious.Window.SettingsPage" + ], + "RU": "Нет настроек, соответствующих поиску.", + "ZH": "没有与搜索条件匹配的设置。", + "isReviewed": "True" } } diff --git a/Furious/Window/AGENTS.md b/Furious/Window/AGENTS.md index b9cce70..bd26da3 100644 --- a/Furious/Window/AGENTS.md +++ b/Furious/Window/AGENTS.md @@ -51,6 +51,11 @@ state. secret-bearing QR content. Reuse plugin export semantics and never log the encoded URI. - Search debounce belongs to the persistent page: clear/submit cancels pending work, hide stops it, and show applies only the current query. Find shortcuts are page-scoped; document editing shortcuts stay with their document widget. + Settings search matches section headings and card titles/descriptions, including plugin metadata and English/current + locale text. It filters presentation only: never search control values, apply preferences, enable disabled cards, or + reveal platform-unavailable sections. The search control owns debounce and its Find action; the page supplies its + existing cards/sections. Refilter after all translated labels update. The Settings organization cases + in `tests/test_ui_behavior.py` cover these boundaries with real input and navigation. - Log freezing is currently commented out. Its restoration notes and commented cases in `tests/test_ui_behavior.py` are design references, not executed coverage. If restored, keep freezing a presentation policy and verify filtering/catch-up after clear, eviction, and navigation against the live diff --git a/Furious/Window/SettingsPage.py b/Furious/Window/SettingsPage.py index 571a6ab..7b3faba 100644 --- a/Furious/Window/SettingsPage.py +++ b/Furious/Window/SettingsPage.py @@ -39,9 +39,10 @@ from Furious.Service.TrafficStatsManager import ( METRICS_COLLECTION_SETTING, ) from Furious.Window.TunSettingsDialog import tunModeTitle -from Furious.Qt.Signals import connectWeakly +from Furious.Qt.Signals import connectWeakly, singleShotWeakly from PySide6 import QtCore +from PySide6.QtGui import QKeySequence from PySide6.QtWidgets import * from collections import Counter @@ -636,6 +637,93 @@ class _SettingsSection(QWidget): card.setIconFileName(None) +class _SettingsSearchLineEdit(AppQLineEdit): + """Own settings filtering and debounce within the persistent search control.""" + + def __init__(self): + super().__init__() + + self.setClearButtonEnabled(True) + self.setPlaceholderText(_('Search settings...')) + self.setMinimumWidth(180) + self.setMaximumWidth(600) + + self._sections = [] + self._pluginSections = [] + self._pluginHeading = None + self._emptyLabel = None + + self._searchTimer = QtCore.QTimer(self) + self._searchTimer.setSingleShot(True) + self._searchTimer.setInterval(180) + + connectWeakly(self._searchTimer.timeout, self, 'applySearch') + connectWeakly(self.textChanged, self, '_scheduleSearch') + connectWeakly(self.returnPressed, self, 'applySearch') + + def configureSections(self, sections, pluginSections, pluginHeading, emptyLabel): + self._sections = sections + self._pluginSections = pluginSections + self._pluginHeading = pluginHeading + self._emptyLabel = emptyLabel + + @QtCore.Slot(str) + def _scheduleSearch(self, text): + """Debounce typing while making clear immediately restore the cards.""" + if not text.strip(): + self.applySearch() + elif self.isVisible(): + self._searchTimer.start() + + @QtCore.Slot() + def applySearch(self): + """Filter descriptive card text without reading or changing setting values.""" + self._searchTimer.stop() + + if self._emptyLabel is None: + return + + terms = self.text().casefold().split() + matched = False + pluginMatched = False + + for section in self._sections: + sectionMatched = False + + for card in section.cards: + labels = (section.titleLabel, card.titleLabel, card.descriptionLabel) + texts = [label.text() for label in labels] + texts.extend( + _(label.text(), locale='EN') + for label in labels + if label.translatable + ) + searchableText = ' '.join(texts).casefold() + visible = all(term in searchableText for term in terms) + + card.setVisible(visible) + sectionMatched |= visible + + section.setVisible(sectionMatched) + matched |= sectionMatched + + if section in self._pluginSections: + pluginMatched |= sectionMatched + + self._pluginHeading.setVisible(pluginMatched) + self._emptyLabel.setVisible(not matched) + + @QtCore.Slot() + def focusSearch(self): + self.setFocus(QtCore.Qt.ShortcutFocusReason) + self.selectAll() + + def hideEvent(self, event): + self._searchTimer.stop() + + super().hideEvent(event) + + class SettingsPage(Mixins.QTranslatable, QMainWindow): """Compose application settings without owning their operational logic.""" @@ -679,6 +767,17 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow): self.pageTitleLabel = AppQLabel(_('Settings')) self.pageTitleLabel.setObjectName('SettingsPageTitle') + self.searchLineEdit = _SettingsSearchLineEdit() + + self.findAction = AppQAction( + _('Search'), + parent=self.searchLineEdit, + callback=self.searchLineEdit.focusSearch, + shortcut=QKeySequence.StandardKey.Find, + ) + self.findAction.setShortcutContext(QtCore.Qt.WidgetWithChildrenShortcut) + self.addAction(self.findAction) + self.generalSection = _SettingsSection(_('General')) self.tunSection = _SettingsSection('TUN', translatable=False) self.connectionSection = _SettingsSection(_('Connection and Interface')) @@ -937,7 +1036,31 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow): contentLayout = QVBoxLayout(contentWidget) contentLayout.setContentsMargins(20, 18, 20, 24) contentLayout.setSpacing(22) - contentLayout.addWidget(self.pageTitleLabel) + + headerLayout = QHBoxLayout() + headerLayout.setContentsMargins(0, 0, 0, 0) + headerLayout.addWidget(self.pageTitleLabel) + headerLayout.addStretch(1) + headerLayout.addWidget(self.searchLineEdit, 3) + contentLayout.addLayout(headerLayout) + + self.emptySearchLabel = AppQLabel(_('No settings match your search.')) + self.emptySearchLabel.setWordWrap(True) + self.emptySearchLabel.hide() + + contentLayout.addWidget(self.emptySearchLabel) + + # Register only platform-available sections; filtering must not reveal + # controls deliberately omitted from this installation. + self._searchSections = [self.generalSection] + + if not self.tunSection.isHidden(): + self._searchSections.append(self.tunSection) + + self._searchSections.extend( + [self.connectionSection, *self.pluginSections, self.applicationSection] + ) + contentLayout.addWidget(self.generalSection) contentLayout.addWidget(self.tunSection) contentLayout.addWidget(self.connectionSection) @@ -958,6 +1081,13 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow): self.scrollArea.setWidget(contentWidget) self.setCentralWidget(self.scrollArea) + self.searchLineEdit.configureSections( + self._searchSections, + self.pluginSections, + self.pluginSettingsTitleLabel, + self.emptySearchLabel, + ) + self.retranslate() @staticmethod @@ -1213,6 +1343,13 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow): self.languageCard.sync() + self.searchLineEdit.applySearch() + def retranslate(self): """Synchronize page-level dynamic state after a language change.""" self.languageCard.sync() + + # The page is registered before its labels in the translation pool. + # Filter after the whole language switch has updated descriptive text. + if self.isVisible(): + singleShotWeakly(0, self.searchLineEdit, 'applySearch') diff --git a/tests/README.md b/tests/README.md index 25d5fea..2e04855 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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 organization, 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 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_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, Tests-menu selection color, shared settings, batched profile mutation and cancellation. | | [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. | diff --git a/tests/test_ui_behavior.py b/tests/test_ui_behavior.py index 5c7aa3e..c57c94f 100644 --- a/tests/test_ui_behavior.py +++ b/tests/test_ui_behavior.py @@ -89,7 +89,12 @@ from Furious.Models import ( Protocol, ServerProfile, ) -from Furious.Plugins.API import RoutingOption +from Furious.Plugins.API import ( + PluginSettingControl, + PluginSettingDescriptor, + PluginSettingsSection, + RoutingOption, +) from Furious.Repository import Storage from Furious.Repository.Routings import UserRoutings from Furious.Qt import ( @@ -388,7 +393,9 @@ class SettingsPageOrganizationTest(unittest.TestCase): """Finish deferred Settings-page deletion between tests.""" collectAtBoundary() - def buildPage(self, *, platform='Windows', flatpakID='', isAdmin=False): + def buildPage( + self, *, platform='Windows', flatpakID='', isAdmin=False, pluginSections=() + ): """Build one isolated Settings page for the requested platform state.""" callbacks = { 'checkForUpdates': mock.Mock(), @@ -399,6 +406,10 @@ class SettingsPageOrganizationTest(unittest.TestCase): proxyBypassDialog = mock.Mock() networkTestDialog = mock.Mock() + def buildPluginSections(page): + for section in pluginSections: + page._addPluginDescriptorSection(section) + with ( isolatedSettings(), mock.patch('Furious.Window.SettingsPage.PLATFORM', platform), @@ -418,7 +429,9 @@ class SettingsPageOrganizationTest(unittest.TestCase): 'Furious.Window.SettingsPage.AppSettings.isStateON_', return_value=False, ), - mock.patch.object(SettingsPage, '_buildPluginSections'), + mock.patch.object( + SettingsPage, '_buildPluginSections', buildPluginSections + ), mock.patch( 'Furious.Window.SettingsPage.AppSettingsController' ) as controllerFactory, @@ -436,6 +449,125 @@ class SettingsPageOrganizationTest(unittest.TestCase): return page, callbacks + def testSearchMatchesDescriptionsAndSectionsWithoutApplyingSettings(self): + with isolatedSettings(): + AppSettings.set('Language', 'EN') + page, callbacks = self.buildPage() + page.show() + page.setConnectionControlsEnabled(False) + processQtEvents() + + page.searchLineEdit.setText('OPERATING system') + QTest.keyClick(page.searchLineEdit, QtCore.Qt.Key.Key_Return) + processQtEvents() + + self.assertFalse(page.systemProxyCard.isHidden()) + self.assertTrue(page.generalSection.isHidden()) + self.assertTrue(page.tunSection.isHidden()) + self.assertFalse(page.systemProxyCard.comboBox.isEnabled()) + self.assertFalse(page.emptySearchLabel.isVisible()) + self.assertTrue( + all(callback.call_count == 0 for callback in callbacks.values()) + ) + + page.searchLineEdit.setText('TUN') + page.searchLineEdit.applySearch() + self.assertTrue(all(not card.isHidden() for card in page.tunSection.cards)) + self.assertFalse(page.tunBackendCard.isEnabled()) + + page.searchLineEdit.setText('[') + page.searchLineEdit.applySearch() + self.assertTrue(page.emptySearchLabel.isVisible()) + + page.searchLineEdit.clear() + self.assertFalse(page.emptySearchLabel.isVisible()) + self.assertTrue( + all(not section.isHidden() for section in page._searchSections) + ) + + page.close() + page.deleteLater() + + def testSearchIncludesPluginMetadataButNotControlValuesOrUnavailableSections(self): + callback = mock.Mock() + descriptor = PluginSettingsSection( + 'fixture', + 'Vendor [Example]', + ( + PluginSettingDescriptor( + 'diagnostics', + 'Inspect endpoint', + description='Collect local diagnostics', + control=PluginSettingControl.Action, + callback=callback, + buttonText='private-control-value', + ), + ), + ) + page, _callbacks = self.buildPage( + platform='Linux', + flatpakID='io.github.LorenEteval.Furious', + pluginSections=(descriptor,), + ) + page.show() + processQtEvents() + + page.searchLineEdit.setText('vendor [example]') + page.searchLineEdit.applySearch() + self.assertTrue(page.pluginSettingsTitleLabel.isVisible()) + self.assertTrue(page.pluginSections[0].isVisible()) + self.assertTrue(page.tunSection.isHidden()) + + page.searchLineEdit.setText('private-control-value') + page.searchLineEdit.applySearch() + self.assertTrue(page.emptySearchLabel.isVisible()) + callback.assert_not_called() + + page.searchLineEdit.clear() + self.assertTrue(page.tunSection.isHidden()) + self.assertTrue(page.pluginSections[0].isVisible()) + + page.close() + page.deleteLater() + + def testSearchFindShortcutLanguageSwitchAndHiddenDebounce(self): + with isolatedSettings(): + AppSettings.set('Language', 'EN') + page, _callbacks = self.buildPage() + page.show() + page.activateWindow() + page.aboutCard.button.setFocus() + processQtEvents() + + QTest.keySequence(page.aboutCard.button, page.findAction.shortcut()) + self.assertTrue(page.searchLineEdit.hasFocus()) + + QTest.keyClicks(page.searchLineEdit, 'metrics') + self.assertTrue(page.searchLineEdit._searchTimer.isActive()) + page.hide() + self.assertFalse(page.searchLineEdit._searchTimer.isActive()) + page.show() + processQtEvents() + self.assertTrue(page.metricsCollectionCard.isVisible()) + self.assertTrue(page.generalSection.isHidden()) + + AppSettings.set('Language', 'ZH') + Mixins.QTranslatable.retranslateAll() + processQtEvents() + page.searchLineEdit.setText(_('Enable Metrics Collection')) + page.searchLineEdit.applySearch() + self.assertTrue(page.metricsCollectionCard.isVisible()) + + page.searchLineEdit.setText('Enable Metrics Collection') + page.searchLineEdit.applySearch() + self.assertTrue(page.metricsCollectionCard.isVisible()) + + page.close() + page.deleteLater() + + AppSettings.set('Language', 'EN') + Mixins.QTranslatable.retranslateAll() + def testGeneralEndsWithSystemAndEnvironmentActions(self): """Keep preferences first and Application focused on maintenance/about.""" page, _callbacks = self.buildPage()