diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 1d1839f2c7a..bed0499ea2b 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -226,6 +226,7 @@ textParagraphRegex = string(default="{configDefaults.DEFAULT_TEXT_PARAGRAPH_REGEX}") # Element types available for cycling in browse touch mode. browseModeTouchNavigationElements = string_list(default=list("heading", "link", "formField", "list", "table")) + findHistory = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="enabled") [touch] enabled = boolean(default=true) diff --git a/source/cursorManager.py b/source/cursorManager.py index 4f4231dcc79..e6c361c65fb 100644 --- a/source/cursorManager.py +++ b/source/cursorManager.py @@ -1,7 +1,8 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2006-2022 NV Access Limited, Joseph Lee, Derek Riemer, Davy Kager, Rob Meredith -# This file is covered by the GNU General Public License. -# See the file COPYING for more details. +# Copyright (C) 2006-2026 NV Access Limited, Joseph Lee, Derek Riemer, Davy Kager, Rob Meredith, +# Marlon Brandão de Sousa, Leonard de Ruijter +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt """ Implementation of cursor managers. @@ -33,6 +34,9 @@ from textInfos import DocumentWithPageTurns from logHandler import log +MAX_SEARCH_HISTORY_ENTRIES = 20 +"""The maximum number of entries kept in the in-memory browse mode search history.""" + class FindDialog( gui.contextHelp.ContextHelpMixin, @@ -44,7 +48,24 @@ class FindDialog( shouldSuspendConfigProfileTriggers = True - def __init__(self, parent, cursorManager, text, caseSensitivity, reverse=False): + def __init__( + self, + parent: wx.Window, + cursorManager: "CursorManager", + text: str, + caseSensitivity: bool, + reverse: bool = False, + searchEntries: list[str] | None = None, + ): + """ + :param parent: The parent window for this dialog. + :param cursorManager: The cursor manager to perform the search in. + :param text: The initial text of the search field. + :param caseSensitivity: The initial state of the case sensitivity check box. + :param reverse: Whether to search backwards from the caret. + :param searchEntries: Previously searched terms to offer in the search field, + most-recent first. Only used when the search history setting is enabled. + """ # Translators: Title of a dialog to find text. super().__init__(parent, title=_("Find")) @@ -55,7 +76,16 @@ def __init__(self, parent, cursorManager, text, caseSensitivity, reverse=False): sHelper = guiHelper.BoxSizerHelper(self, orientation=wx.VERTICAL) # Translators: Dialog text for NvDA's find command. findLabelText = _("Type the text you wish to find") - self.findTextField = sHelper.addLabeledControl(findLabelText, wx.TextCtrl, value=text) + if config.conf["virtualBuffers"]["findHistory"]: + self.findTextField = sHelper.addLabeledControl( + findLabelText, + wx.ComboBox, + choices=searchEntries or [], + value=text, + style=wx.CB_DROPDOWN, + ) + else: + self.findTextField = sHelper.addLabeledControl(findLabelText, wx.TextCtrl, value=text) # Translators: An option in find dialog to perform case-sensitive search. self.caseSensitiveCheckBox = wx.CheckBox(self, wx.ID_ANY, label=_("Case &sensitive")) self.caseSensitiveCheckBox.SetValue(caseSensitivity) @@ -112,6 +142,28 @@ class CursorManager(documentBase.TextContainerObject, baseObject.ScriptableObjec _lastFindText = "" _lastCaseSensitivity = False + _searchEntries: list[str] = [] + """In-memory history of search terms, most-recent first. Cleared on restart.""" + + @classmethod + def _updateSearchHistory(cls, text: str) -> None: + """Add a search term to the front of the shared search history. + + :param text: The term that was just searched for. + An empty string is ignored. + """ + if not text: + return + # wxComboBox on Windows cannot hold two entries differing only in case, + # so dedup case-insensitively and keep the most recently typed casing. + foldedText = text.casefold() + for index, entry in enumerate(cls._searchEntries): + if entry.casefold() == foldedText: + del cls._searchEntries[index] + break + cls._searchEntries.insert(0, text) + del cls._searchEntries[MAX_SEARCH_HISTORY_ENTRIES:] + def __init__(self, *args, **kwargs): super(CursorManager, self).__init__(*args, **kwargs) self.initCursorManager() @@ -215,12 +267,21 @@ def doFindText(self, text, reverse=False, caseSensitive=False, willSayAllResume= ) CursorManager._lastFindText = text CursorManager._lastCaseSensitivity = caseSensitive + if config.conf["virtualBuffers"]["findHistory"]: + CursorManager._updateSearchHistory(text) def script_find(self, gesture, reverse=False): # #8566: We need this to be a modal dialog, but it mustn't block this script. def run(): gui.mainFrame.prePopup() - d = FindDialog(gui.mainFrame, self, self._lastFindText, self._lastCaseSensitivity, reverse) + d = FindDialog( + gui.mainFrame, + self, + self._lastFindText, + self._lastCaseSensitivity, + reverse, + self._searchEntries, + ) d.ShowModal() gui.mainFrame.postPopup() diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index f81b9771af7..7c09b51c224 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -2666,6 +2666,17 @@ def makeSettings(self, settingsSizer): ) self.trapNonCommandGesturesCheckBox.SetValue(config.conf["virtualBuffers"]["trapNonCommandGestures"]) + self.searchHistoryCombo: nvdaControls.FeatureFlagCombo = sHelper.addLabeledControl( + labelText=_( + # Translators: This is the label for a combo box in the browse mode settings panel. + "&Keep search history", + ), + wxCtrlClass=nvdaControls.FeatureFlagCombo, + keyPath=["virtualBuffers", "findHistory"], + conf=config.conf, + ) + self.bindHelpEvent("BrowseModeSettingsSearchHistory", self.searchHistoryCombo) + # browseMode imports gui, which imports from settingsDialogs, so a top-level import # would create a circular dependency. Keep this import lazy. import browseMode @@ -2702,6 +2713,7 @@ def onSave(self): config.conf["virtualBuffers"]["trapNonCommandGestures"] = ( self.trapNonCommandGesturesCheckBox.IsChecked() ) + self.searchHistoryCombo.saveCurrentValueToConf() config.conf["virtualBuffers"]["browseModeTouchNavigationElements"] = [ itemType for i, (itemType, _label) in enumerate(self._browseModeElements) diff --git a/tests/unit/test_cursorManager.py b/tests/unit/test_cursorManager.py index 6247dc68670..5171cf56afb 100644 --- a/tests/unit/test_cursorManager.py +++ b/tests/unit/test_cursorManager.py @@ -1,11 +1,12 @@ # A part of NonVisual Desktop Access (NVDA) -# This file is covered by the GNU General Public License. -# See the file COPYING for more details. -# Copyright (C) 2017-2023 NV Access Limited +# Copyright (C) 2017-2026 NV Access Limited, Leonard de Ruijter +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt """Unit tests for the cursorManager module.""" import unittest +from cursorManager import MAX_SEARCH_HISTORY_ENTRIES from .textProvider import CursorManager @@ -233,3 +234,42 @@ def test_selectAllFromMiddle(self): def test_selectAllFromEnd(self): self._selectAllTest(2) # Caret at "c" + + +class TestSearchHistory(unittest.TestCase): + """Tests the pure list logic of CursorManager._updateSearchHistory (#8482). + This is class-level shared state, so it is reset before and after each test. + """ + + def setUp(self): + CursorManager._searchEntries = [] + + def tearDown(self): + CursorManager._searchEntries = [] + + def test_appendingTermPutsItAtFront(self): + CursorManager._updateSearchHistory("foo") + self.assertEqual(CursorManager._searchEntries, ["foo"]) + + def test_emptyStringIsIgnored(self): + CursorManager._updateSearchHistory("foo") + CursorManager._updateSearchHistory("") + self.assertEqual(CursorManager._searchEntries, ["foo"]) + + def test_retypingExistingTermPromotesItToFrontWithoutDuplicate(self): + CursorManager._updateSearchHistory("foo") + CursorManager._updateSearchHistory("bar") + CursorManager._updateSearchHistory("foo") + self.assertEqual(CursorManager._searchEntries, ["foo", "bar"]) + + def test_caseOnlyVariantDedupsKeepingNewestCasing(self): + CursorManager._updateSearchHistory("Car") + CursorManager._updateSearchHistory("car") + self.assertEqual(CursorManager._searchEntries, ["car"]) + + def test_exceedingMaxEntriesTruncatesOldest(self): + for index in range(MAX_SEARCH_HISTORY_ENTRIES + 1): + CursorManager._updateSearchHistory(f"term{index}") + self.assertEqual(len(CursorManager._searchEntries), MAX_SEARCH_HISTORY_ENTRIES) + self.assertEqual(CursorManager._searchEntries[0], f"term{MAX_SEARCH_HISTORY_ENTRIES}") + self.assertNotIn("term0", CursorManager._searchEntries) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 6befac7d53e..9bca27bf62b 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -20,6 +20,7 @@ * In modes that show a continuation mark, when a word is cut across rows, the last cell of the row now shows a continuation mark (braille dots 7-8) so it is clear that the word continues on the next row. * The "At word or syllable boundaries" option uses hyphenation dictionaries to split long words at syllable boundaries when they do not fit on the display. * Magnifier: A new unassigned command has been added to move the mouse cursor to the center of the magnified view. (#20127, @CyrilleB79) +* The Find dialog in browse mode can now remember previously searched terms in a search history combo box, which can be turned off via a new "Keep search history" browse mode setting. (#8482, @marlon-sousa, @LeonarddeR) ### Changes diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index e87757b3da8..60ab87d740a 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -1180,6 +1180,7 @@ This dialog allows you to search for terms in the current document. In the "Type the text you wish to find" field, the text to be found can be entered. The "Case sensitive" checkbox makes the search consider uppercase and lowercase letters differently. For example, with "Case sensitive" selected you can find "NV Access" but not "nv access". +When the [Keep search history](#BrowseModeSettingsSearchHistory) browse mode setting is enabled, this field is a combo box that also offers the terms you have recently searched for (up to 20), which are cleared when NVDA restarts. Use the following keys for performing searches: @@ -3415,6 +3416,17 @@ Enabled by default, this option allows you to choose if gestures (such as key pr As an example, if enabled and the letter j was pressed, it would be trapped from reaching the document, even though it is not a quick navigation command nor is it likely to be a command in the application itself. In this case NVDA will tell Windows to play a default sound whenever a key which gets trapped is pressed. +##### Keep search history {#BrowseModeSettingsSearchHistory} + +Enabled by default, this option determines whether the [Find dialog](#SearchingForText) remembers the terms you have searched for during the current NVDA session. +When enabled, the Find dialog offers up to 20 recently used search terms from a combo box; this history is kept in memory only and is cleared when NVDA restarts. +If the combo box interferes with your input method, such as with some IMEs, you can set this option to Disabled to return the Find dialog to a plain edit field. + +| . {.hideHeaderRow} |.| +|---|---| +|Options |Default (Enabled), Disabled, Enabled| +|Default |Enabled| + ##### Browse mode touch navigation elements {#BrowseModeSettingsBrowseModeNavigationElements} This list allows you to choose which element types are available when cycling through elements in browse touch mode.