mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-07 22:38:21 +03:00
Add settings search
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Vendored
+18
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user