Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions source/config/configSpec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
73 changes: 67 additions & 6 deletions source/cursorManager.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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"))

Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()

Expand Down
12 changes: 12 additions & 0 deletions source/gui/settingsDialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
46 changes: 43 additions & 3 deletions tests/unit/test_cursorManager.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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)
1 change: 1 addition & 0 deletions user_docs/en/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions user_docs/en/userGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
<!-- KC:beginInclude -->

Expand Down Expand Up @@ -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.
Expand Down