Repository: nvaccess/nvda

Issue:
Introduce search history in NvDA Find dialog
Hi,

This is the "issueformation" (combination of the words "issue" and "transformation") of the PR #6484:

### Rationale
Suppose one wishes to find multiple texts from different applications. Currently, in order to find new text in any application that supports cursor manager, users must enter the new text. In case users may wish to find something they've searched before, a search history in the form of a combo box should be provided.

### Steps to reproduce:
1. Open multiple document windows (web browsers, PDF documents, several Word documents and such), all with browse mode turned on.
2. While using one of them, press Control+NVDA+F to open Find dialog.
3. Search for some text and press Enter.
4. Reopen Find dialog (instead of using find next/previous command) and search for another text.
5. Repeat step 4.

### Actual behavior:
Users must enter new text again.

### Expected behavior:
Users can choose items from search history.

### System configuration:

#### NVDA Installed/portable/running from source:
Installed

#### NVDA version:
Alpha/future version

#### Windows version:
Windows 10 Version 1803

#### Name and version of other software in use when reproducing the issue:
N/A

#### Other information about your system:
N/A

### Other questions:

#### Does the issue still occur after restarting your PC?
Yes

#### Have you tried any other versions of NVDA?
Yes (perpetual as this is a new feature)

### Technical details
One way to implement this is via wx.ComboBox and hosting search history (a list/priority queue) inside Cursor Manager class (the base class, that is). When Find dialog opens, present this combo box with entries coming from search history list, with find next (NvDA+F3)/previous (NVDA+Shift+F3) searching the topmost item.

Special corner case: what if users type something that NVDA knows is already part of search history (the whole text, that is)? If so, bump this text to the top if it isn't.

Another case: suppose there is already an entry in search history and the user types the same entry that only differs in case. If so, the best path might be to compare the texts regardless of case, or if case sensitivity is turned on, just prepend the new variant.

For a code example, see StationPlaylist Studio add-on.

Thanks.


Discussion:
I also support the implementation of this handy feature.

Resolving patch:
diff --git a/source/cursorManager.py b/source/cursorManager.py
--- a/source/cursorManager.py
+++ b/source/cursorManager.py
@@ -1,5 +1,4 @@
 #cursorManager.py
-#A part of NonVisual Desktop Access (NVDA)
 #Copyright (C) 2006-2018 NV Access Limited, Joseph Lee, Derek Riemer, Davy Kager
 #This file is covered by the GNU General Public License.
 #See the file COPYING for more details.
@@ -27,11 +26,15 @@
 import ui
 from textInfos import DocumentWithPageTurns
 
+# search history list constants
+SEARCH_HISTORY_MOST_RECENT_INDEX = 0
+SEARCH_HISTORY_LEAST_RECENT_INDEX = 19
+
 class FindDialog(wx.Dialog):
 	"""A dialog used to specify text to find in a cursor manager.
 	"""
 
-	def __init__(self, parent, cursorManager, text, caseSensitivity):
+	def __init__(self, parent, cursorManager, caseSensitivity, searchEntries):
 		# Translators: Title of a dialog to find text.
 		super(FindDialog, self).__init__(parent, wx.ID_ANY, _("Find"))
 		# Have a copy of the active cursor manager, as this is needed later for finding text.
@@ -42,8 +45,11 @@ def __init__(self, parent, cursorManager, text, caseSensitivity):
 		# Translators: Dialog text for NvDA's find command.
 		textToFind = wx.StaticText(self, wx.ID_ANY, label=_("Type the text you wish to find"))
 		findSizer.Add(textToFind)
-		self.findTextField = wx.TextCtrl(self, wx.ID_ANY)
-		self.findTextField.SetValue(text)
+		self.findTextField = wx.ComboBox(self, wx.ID_ANY, choices = searchEntries,style=wx.CB_DROPDOWN)
+
+		# if there is a previous list of searched entries, make sure we present the last searched term  selected by default
+		if searchEntries:
+			self.findTextField.Select(SEARCH_HISTORY_MOST_RECENT_INDEX)
 		findSizer.Add(self.findTextField)
 		mainSizer.Add(findSizer,border=20,flag=wx.LEFT|wx.RIGHT|wx.TOP)
 		# Translators: An option in find dialog to perform case-sensitive search.
@@ -59,8 +65,36 @@ def __init__(self, parent, cursorManager, text, caseSensitivity):
 		self.CentreOnScreen()
 		self.findTextField.SetFocus()
 
+	def updateSearchEntries(self, searchEntries, currentSearchTerm):
+		if not currentSearchTerm:
+			return
+		if not searchEntries:
+			searchEntries.insert(SEARCH_HISTORY_MOST_RECENT_INDEX, currentSearchTerm)
+			return
+		# we can not accept entries that differ only on text case
+		#because of a wxComboBox limitation on MS Windows
+		# see https://wxpython.org/Phoenix/docs/html/wx.ComboBox.html
+		#notice also that python 2 does not offer caseFold functionality
+		# so lower is the best we can have for comparing strings
+		for index, item in enumerate(searchEntries):
+			if(item.lower() == currentSearchTerm.lower()):
+				# if the user has selected a previous search term in the list or retyped an already listed term , we need to make sure the 
+				# current search term becomes the first item of the list, so that it will appear selected by default when the dialog is
+				# shown again. If the current search term differs from the current item only in case letters, we will choose to store the
+				# new search as we can not store both.
+				searchEntries.pop(index)
+				searchEntries.insert(SEARCH_HISTORY_MOST_RECENT_INDEX, currentSearchTerm)
+				return
+		# not yet listed. Save it.
+		if len(searchEntries) > SEARCH_HISTORY_LEAST_RECENT_INDEX:
+			self._truncateSearchHistory(searchEntries)
+		searchEntries.insert(SEARCH_HISTORY_MOST_RECENT_INDEX, currentSearchTerm)
+		
 	def onOk(self, evt):
 		text = self.findTextField.GetValue()
+		# update the list of searched entries so that it can be exibited in the next find dialog call
+		self.updateSearchEntries(self.activeCursorManager._searchEntries, text)
+		
 		caseSensitive = self.caseSensitiveCheckBox.GetValue()
 		# We must use core.callLater rather than wx.CallLater to ensure that the callback runs within NVDA's core pump.
 		# If it didn't, and it directly or indirectly called wx.Yield, it could start executing NVDA's core pump from within the yield, causing recursion.
@@ -70,6 +104,9 @@ def onOk(self, evt):
 	def onCancel(self, evt):
 		self.Destroy()
 
+	def _truncateSearchHistory(self, entries):
+		del entries[SEARCH_HISTORY_LEAST_RECENT_INDEX:]
+
 class CursorManager(documentBase.TextContainerObject,baseObject.ScriptableObject):
 	"""
 	A mix-in providing caret navigation and selection commands for the object's virtual text range.
@@ -86,8 +123,13 @@ class CursorManager(documentBase.TextContainerObject,baseObject.ScriptableObject
 	# Translators: the script category for browse mode
 	scriptCategory=SCRCAT_BROWSEMODE
 
-	_lastFindText=""
 	_lastCaseSensitivity=False
+	#  History of search terms.
+	# Sorted in most to least recently searched order.
+	# First, most recently searched item index: SEARCH_HISTORY_MOST_RECENT_INDEX
+	# Last, least recently searched item index: SEARCH_HISTORY_LEAST_RECENT_INDEX
+	# Items that differ only by case will only have one entry. 
+	_searchEntries = []
 
 	def __init__(self, *args, **kwargs):
 		super(CursorManager, self).__init__(*args, **kwargs)
@@ -158,11 +200,10 @@ def doFindText(self,text,reverse=False,caseSensitive=False):
 			speech.speakTextInfo(info,reason=controlTypes.REASON_CARET)
 		else:
 			wx.CallAfter(gui.messageBox,_('text "%s" not found')%text,_("Find Error"),wx.OK|wx.ICON_ERROR)
-		CursorManager._lastFindText=text
 		CursorManager._lastCaseSensitivity=caseSensitive
 
 	def script_find(self,gesture):
-		d = FindDialog(gui.mainFrame, self, self._lastFindText, self._lastCaseSensitivity)
+		d = FindDialog(gui.mainFrame, self, self._lastCaseSensitivity, self._searchEntries)
 		gui.mainFrame.prePopup()
 		d.Show()
 		gui.mainFrame.postPopup()
@@ -170,18 +211,18 @@ def script_find(self,gesture):
 	script_find.__doc__ = _("find a text string from the current cursor position")
 
 	def script_findNext(self,gesture):
-		if not self._lastFindText:
+		if not self._searchEntries:
 			self.script_find(gesture)
 			return
-		self.doFindText(self._lastFindText, caseSensitive = self._lastCaseSensitivity)
+		self.doFindText(self._searchEntries[SEARCH_HISTORY_MOST_RECENT_INDEX], caseSensitive = self._lastCaseSensitivity)
 	# Translators: Input help message for find next command.
 	script_findNext.__doc__ = _("find the next occurrence of the previously entered text string from the current cursor's position")
 
 	def script_findPrevious(self,gesture):
-		if not self._lastFindText:
+		if not self._searchEntries:
 			self.script_find(gesture)
 			return
-		self.doFindText(self._lastFindText,reverse=True, caseSensitive = self._lastCaseSensitivity)
+		self.doFindText(self._searchEntries[SEARCH_HISTORY_MOST_RECENT_INDEX], reverse=True, caseSensitive = self._lastCaseSensitivity)
 	# Translators: Input help message for find previous command.
 	script_findPrevious.__doc__ = _("find the previous occurrence of the previously entered text string from the current cursor's position")
 


Test patch: