From 3eb954cd02a321af5e8d347a3c06c75f91efbc81 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Thu, 9 Jul 2026 23:15:33 +0200 Subject: [PATCH 1/6] feat(cursorManager): add in-memory search history for browse mode find dialog (#8482) Introduce the "findHistory" feature flag (config.conf["virtualBuffers"]["findHistory"], enabled by default) and the core CursorManager._updateSearchHistory logic that maintains a most-recent-first, case-insensitively deduped, 20-entry in-memory history of search terms typed in the Find dialog. FindDialog now swaps in a wx.ComboBox pre-populated with that history when the flag is on; with the flag off, behavior is unchanged (plain wx.TextCtrl). History updates happen at a single choke point in doFindText, so it covers both the dialog and find-next/previous. Co-Authored-By: Claude Sonnet 5 --- source/config/configSpec.py | 3 ++- source/cursorManager.py | 39 ++++++++++++++++++++++++--- tests/unit/test_cursorManager.py | 46 +++++++++++++++++++++++++++++--- 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 1d1839f2c7a..09547278ed4 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -2,7 +2,7 @@ # Copyright (C) 2006-2026 NV Access Limited, Babbage B.V., Davy Kager, Bill Dengler, Julien Cochuyt, # Joseph Lee, Dawid Pieper, mltony, Bram Duvigneau, Cyrille Bougot, Rob Meredith, # Burman's Computer and Education Ltd., Leonard de Ruijter, Łukasz Golonka, Cary-rowen, -# Wang Chong, Kefas Lungu +# Wang Chong, Kefas Lungu, Marlon Brandão de Sousa # 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 @@ -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..b45386f27fc 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 +#: The maximum number of entries kept in the in-memory browse mode search history. +MAX_SEARCH_HISTORY_ENTRIES = 20 + class FindDialog( gui.contextHelp.ContextHelpMixin, @@ -55,7 +59,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 bool(config.conf["virtualBuffers"]["findHistory"]): + self.findTextField = sHelper.addLabeledControl( + findLabelText, + wx.ComboBox, + choices=cursorManager._searchEntries, + 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 +125,22 @@ class CursorManager(documentBase.TextContainerObject, baseObject.ScriptableObjec _lastFindText = "" _lastCaseSensitivity = False + #: In-memory history of search terms, most-recent first. Cleared on restart. + _searchEntries: list[str] = [] + + @classmethod + def _updateSearchHistory(cls, text: str) -> None: + 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. + for index, entry in enumerate(cls._searchEntries): + if entry.casefold() == text.casefold(): + 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,6 +244,8 @@ def doFindText(self, text, reverse=False, caseSensitive=False, willSayAllResume= ) CursorManager._lastFindText = text CursorManager._lastCaseSensitivity = caseSensitive + if bool(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. diff --git a/tests/unit/test_cursorManager.py b/tests/unit/test_cursorManager.py index 6247dc68670..852182a1845 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, 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 """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) From b8c6f55e9b61cf7968361619ce8a54442aecdd2f Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Thu, 9 Jul 2026 23:22:45 +0200 Subject: [PATCH 2/6] Fix copyright attribution in non-authored files (#8482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove Marlon Brandão de Sousa from copyright headers in configSpec.py and test_cursorManager.py where he did not author the content. Per copyrightHeaders.md, contributors should only appear in files they actually authored. Marlon's find-history feature attribution remains correct in cursorManager.py. Co-Authored-By: Claude Opus 4.8 --- source/config/configSpec.py | 2 +- tests/unit/test_cursorManager.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 09547278ed4..bed0499ea2b 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -2,7 +2,7 @@ # Copyright (C) 2006-2026 NV Access Limited, Babbage B.V., Davy Kager, Bill Dengler, Julien Cochuyt, # Joseph Lee, Dawid Pieper, mltony, Bram Duvigneau, Cyrille Bougot, Rob Meredith, # Burman's Computer and Education Ltd., Leonard de Ruijter, Łukasz Golonka, Cary-rowen, -# Wang Chong, Kefas Lungu, Marlon Brandão de Sousa +# Wang Chong, Kefas Lungu # 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 diff --git a/tests/unit/test_cursorManager.py b/tests/unit/test_cursorManager.py index 852182a1845..5171cf56afb 100644 --- a/tests/unit/test_cursorManager.py +++ b/tests/unit/test_cursorManager.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2017-2026 NV Access Limited, Marlon Brandão de Sousa, Leonard de Ruijter +# 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 From 2e5ce1bb8ecf9b5782462cdb801bb7fcedb9c070 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Thu, 9 Jul 2026 23:25:31 +0200 Subject: [PATCH 3/6] feat(gui): add search history combo to browse mode settings (#8482) Add a FeatureFlagCombo control for the virtualBuffers.findHistory feature flag introduced in a previous commit, so users can enable, disable, or restore the default behavior of keeping browse mode find history from the Browse Mode settings panel. --- source/gui/settingsDialogs.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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) From e71b220880d7fffa4bac9f392ef7993eddf3cf10 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Thu, 9 Jul 2026 23:31:37 +0200 Subject: [PATCH 4/6] docs: document browse mode search history feature (#8482) Document the new "Keep search history" browse mode setting: a note in the Find dialog help explaining the combo box behavior, a new Browse Mode Settings subsection (anchor BrowseModeSettingsSearchHistory matching the F1 context help binding), and a New Features changelog entry. --- user_docs/en/changes.md | 1 + user_docs/en/userGuide.md | 12 ++++++++++++ 2 files changed, 13 insertions(+) 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. From a859bf69424664192adef8e04949445b97e7fc0b Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Fri, 10 Jul 2026 07:14:24 +0200 Subject: [PATCH 5/6] Simplify --- source/cursorManager.py | 42 +++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/source/cursorManager.py b/source/cursorManager.py index b45386f27fc..41c968c0958 100644 --- a/source/cursorManager.py +++ b/source/cursorManager.py @@ -48,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")) @@ -59,11 +76,11 @@ 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") - if bool(config.conf["virtualBuffers"]["findHistory"]): + if config.conf["virtualBuffers"]["findHistory"]: self.findTextField = sHelper.addLabeledControl( findLabelText, wx.ComboBox, - choices=cursorManager._searchEntries, + choices=searchEntries or [], value=text, style=wx.CB_DROPDOWN, ) @@ -130,12 +147,18 @@ class CursorManager(documentBase.TextContainerObject, baseObject.ScriptableObjec @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() == text.casefold(): + if entry.casefold() == foldedText: del cls._searchEntries[index] break cls._searchEntries.insert(0, text) @@ -244,14 +267,21 @@ def doFindText(self, text, reverse=False, caseSensitive=False, willSayAllResume= ) CursorManager._lastFindText = text CursorManager._lastCaseSensitivity = caseSensitive - if bool(config.conf["virtualBuffers"]["findHistory"]): + 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() From 8353d07474c38250650fd31e04bf148a47cd85d1 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Tue, 14 Jul 2026 06:50:05 +0200 Subject: [PATCH 6/6] Fixup --- source/cursorManager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/cursorManager.py b/source/cursorManager.py index 41c968c0958..e6c361c65fb 100644 --- a/source/cursorManager.py +++ b/source/cursorManager.py @@ -34,8 +34,8 @@ from textInfos import DocumentWithPageTurns from logHandler import log -#: The maximum number of entries kept in the in-memory browse mode search history. MAX_SEARCH_HISTORY_ENTRIES = 20 +"""The maximum number of entries kept in the in-memory browse mode search history.""" class FindDialog( @@ -142,8 +142,8 @@ class CursorManager(documentBase.TextContainerObject, baseObject.ScriptableObjec _lastFindText = "" _lastCaseSensitivity = False - #: In-memory history of search terms, most-recent first. Cleared on restart. _searchEntries: list[str] = [] + """In-memory history of search terms, most-recent first. Cleared on restart.""" @classmethod def _updateSearchHistory(cls, text: str) -> None: