Add settings search

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-10-06 18:49:44 +08:00
parent 364ba76809
commit e8d5b342fd
5 changed files with 298 additions and 7 deletions
+18 -1
View File
@@ -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"
}
}
+5
View File
@@ -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
+139 -2
View File
@@ -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')
+1 -1
View File
@@ -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. |
+135 -3
View File
@@ -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()