diff --git a/docs/source/usage_interface.rst b/docs/source/usage_interface.rst index 7947747d..47038ae9 100644 --- a/docs/source/usage_interface.rst +++ b/docs/source/usage_interface.rst @@ -152,6 +152,29 @@ convenient if you want to quickly look through all documents in the list in the pressing :kbd:`F9`. +.. _a_ui_edit_search: + +Search & Replace +---------------- + +The document editor has a search and replace bar that can be activated with :kbd:`Ctrl`:kbd:`F` for +search mode or :kbd:`Ctrl`:kbd:`H` for search/replace mode. + +Pressing :kbd:`Return` while in the search box will search for the next occurrence of the word, and +:kbd:`Shift`:kbd:`Return` for the previous. Pressing :kbd:`Return` in the replace box, will replace +the highlighted text and move to the next word. + +There are a number of settings for the search bar available as toggle switches above the search +box. They allows you to search for, in order:,: matched case only, whole word results only, search +using regular expressions, loop search when reaching the end of the document, and move to the next +document when reaching the end. There is also a switch that will try to match the case of the word +when the replacement is made. That is, it will try to keep the word upper, lower, or capitalised to +match the word being replaced. + +The regular expression search is somewhat dependant on which version of Qt your system has. If you +have Qt 5.13 or higher, there is better support for unicode symbols in the search. + + .. _a_ui_edit_auto: Auto-Replace as You Type @@ -432,7 +455,7 @@ Most features are available as keyboard shortcuts. These are as follows: ":kbd:`Ctrl`:kbd:`Backspace`", "Delete previous word in editor." ":kbd:`Ctrl`:kbd:`'`", "Wrap selected text, or word under cursor, in single quotes." ":kbd:`Ctrl`:kbd:`""`", "Wrap selected text, or word under cursor, in double quotes." - ":kbd:`Ctrl`:kbd:`Enter`", "Open the tag or reference under the cursor in the Viewer." + ":kbd:`Ctrl`:kbd:`Retrun`", "Open the tag or reference under the cursor in the Viewer." ":kbd:`Ctrl`:kbd:`Shift`:kbd:`,`", "Open the :guilabel:`Project Settings` dialog." ":kbd:`Ctrl`:kbd:`Shift`:kbd:`/`", "Remove block formatting for block under cursor." ":kbd:`Ctrl`:kbd:`Shift`:kbd:`1`", "Replace occurrence of search word in current document, and search for next occurrence." diff --git a/nw/constants/enum.py b/nw/constants/enum.py index 539c0ae0..4e800666 100644 --- a/nw/constants/enum.py +++ b/nw/constants/enum.py @@ -81,19 +81,14 @@ class nwDocAction(Enum): D_QUOTE = 10 SEL_ALL = 11 SEL_PARA = 12 - FIND = 13 - REPLACE = 14 - GO_NEXT = 15 - GO_PREV = 16 - REPL_NEXT = 17 - BLOCK_H1 = 18 - BLOCK_H2 = 19 - BLOCK_H3 = 20 - BLOCK_H4 = 21 - BLOCK_COM = 22 - BLOCK_TXT = 23 - REPL_SNG = 24 - REPL_DBL = 25 + BLOCK_H1 = 13 + BLOCK_H2 = 14 + BLOCK_H3 = 15 + BLOCK_H4 = 16 + BLOCK_COM = 17 + BLOCK_TXT = 18 + REPL_SNG = 19 + REPL_DBL = 20 # END Enum nwDocAction diff --git a/nw/gui/doceditor.py b/nw/gui/doceditor.py index abafc6fb..779d475d 100644 --- a/nw/gui/doceditor.py +++ b/nw/gui/doceditor.py @@ -660,10 +660,6 @@ class GuiDocEditor(QTextEdit): this class when calling these actions from other classes. """ logger.verbose("Requesting action: %s" % theAction.name) - if not self.hasFocus(): - logger.verbose("Editor does not have focus") - return False - if self.theHandle is None: logger.error("No document open") return False @@ -693,16 +689,6 @@ class GuiDocEditor(QTextEdit): self._makeSelection(QTextCursor.Document) elif theAction == nwDocAction.SEL_PARA: self._makeSelection(QTextCursor.BlockUnderCursor) - elif theAction == nwDocAction.FIND: - self._beginSearch() - elif theAction == nwDocAction.REPLACE: - self._beginReplace() - elif theAction == nwDocAction.GO_NEXT: - self._findNext() - elif theAction == nwDocAction.GO_PREV: - self._findNext(isBackward=True) - elif theAction == nwDocAction.REPL_NEXT: - self._replaceNext() elif theAction == nwDocAction.BLOCK_H1: self._formatBlock(nwDocAction.BLOCK_H1) elif theAction == nwDocAction.BLOCK_H2: @@ -734,6 +720,15 @@ class GuiDocEditor(QTextEdit): """ return self.qDocument.isEmpty() + def anyFocus(self): + """Check if any widget or child widget has focus. + """ + if self.hasFocus(): + return True + if self.isAncestorOf(qApp.focusWidget()): + return True + return False + def revealLocation(self): """Tell the user where on the file system the file in the editor is saved. @@ -827,7 +822,7 @@ class GuiDocEditor(QTextEdit): if self.docSearch.isVisible(): self.docSearch.closeSearch() else: - self._beginSearch() + self.beginSearch() return ## @@ -1142,6 +1137,144 @@ class GuiDocEditor(QTextEdit): ) return + ## + # Search & Replace + ## + + def beginSearch(self): + """Sets the selected text as the search text for the search bar. + """ + theCursor = self.textCursor() + if theCursor.hasSelection(): + self.docSearch.setSearchText(theCursor.selectedText()) + else: + self.docSearch.setSearchText(None) + self.updateDocMargins() + return + + def beginReplace(self): + """Opens the replace line of the search bar and sets the find + text if a selection has been made, and resets the replace text. + """ + theCursor = self.textCursor() + if theCursor.hasSelection(): + self.docSearch.setSearchText(theCursor.selectedText()) + else: + self.docSearch.setSearchText(None) + self.docSearch.setReplaceText("") + self.updateDocMargins() + return + + def findNext(self, goBack=False): + """Searches for the next or previous occurrence of the search + bar text in the document. Wraps around if not found and loop is + enabled, or continues to next file if next file is enabled. + """ + if not self.anyFocus(): + logger.debug("Editor does not have focus") + return False + + if not self.docSearch.isVisible(): + self.beginSearch() + return + + findOpt = QTextDocument.FindFlag(0) + if goBack: + findOpt |= QTextDocument.FindBackward + if self.docSearch.isCaseSense: + findOpt |= QTextDocument.FindCaseSensitively + if self.docSearch.isWholeWord: + findOpt |= QTextDocument.FindWholeWords + + searchFor = self.docSearch.getSearchObject() + wasFound = self.find(searchFor, findOpt) + if not wasFound: + if self.docSearch.doNextFile and not goBack: + self.theParent.openNextDocument( + self.theHandle, wrapAround=self.docSearch.doLoop + ) + elif self.docSearch.doLoop: + theCursor = self.textCursor() + theCursor.movePosition( + QTextCursor.End if goBack else QTextCursor.Start + ) + self.setTextCursor(theCursor) + wasFound = self.find(searchFor, findOpt) + + if wasFound: + theCursor = self.textCursor() + self.lastFind = (theCursor.selectionStart(), theCursor.selectionEnd()) + + return + + def replaceNext(self): + """Searches for the next occurrence of the search bar text in + the document and replaces it with the replace text. Calls search + next automatically when done. + """ + if not self.anyFocus(): + logger.debug("Editor does not have focus") + return False + + if not self.docSearch.isVisible(): + # The search tool is not active, so we activate it. + self.beginSearch() + return + + theCursor = self.textCursor() + if not theCursor.hasSelection(): + # We have no text selected at all, so just make this a + # regular find next call. + self.findNext() + return + + if self.lastFind is None and theCursor.hasSelection(): + # If we have a selection but no search, it may have been the + # text we triggered the search with, in which case we search + # again from the beginning of that selection to make sure we + # have a valid result. + sPos = theCursor.selectionStart() + theCursor.clearSelection() + theCursor.setPosition(sPos) + self.setTextCursor(theCursor) + self.findNext() + theCursor = self.textCursor() + + if self.lastFind is None: + # In case the above didn't find a result, we give up here. + return + + searchFor = self.docSearch.getSearchText() + replWith = self.docSearch.getReplaceText() + + if self.docSearch.doMatchCap: + replWith = transferCase(theCursor.selectedText(), replWith) + + # Make sure the selected text was selected by an actual find + # call, and not the user. + try: + isFind = self.lastFind[0] == theCursor.selectionStart() + isFind &= self.lastFind[1] == theCursor.selectionEnd() + except Exception: + isFind = False + + if isFind: + theCursor.beginEditBlock() + theCursor.removeSelectedText() + theCursor.insertText(replWith) + theCursor.endEditBlock() + theCursor.setPosition(theCursor.selectionEnd()) + self.setTextCursor(theCursor) + logger.verbose("Replaced occurrence of '%s' with '%s' on line %d" % ( + searchFor, replWith, theCursor.blockNumber() + )) + else: + logger.error("The selected text is not a search result, skipping replace") + + self.findNext() + + return + ## # Internal Functions ## @@ -1585,132 +1718,6 @@ class GuiDocEditor(QTextEdit): self._makeSelection(selMode) return - def _beginSearch(self): - """Sets the selected text as the search text for the search bar. - """ - theCursor = self.textCursor() - if theCursor.hasSelection(): - self.docSearch.setSearchText(theCursor.selectedText()) - else: - self.docSearch.setSearchText(None) - self.updateDocMargins() - return - - def _beginReplace(self): - """Opens the replace line of the search bar and sets the find - text if a selection has been made, and resets the replace text. - """ - theCursor = self.textCursor() - if theCursor.hasSelection(): - self.docSearch.setSearchText(theCursor.selectedText()) - else: - self.docSearch.setSearchText(None) - self.docSearch.setReplaceText("") - self.updateDocMargins() - return - - def _findNext(self, isBackward=False): - """Searches for the next or previous occurrence of the search - bar text in the document. Wraps around if not found and loop is - enabled, or continues to next file if next file is enabled. - """ - if not self.docSearch.isVisible(): - self._beginSearch() - return - - findOpt = QTextDocument.FindFlag(0) - if isBackward: - findOpt |= QTextDocument.FindBackward - if self.docSearch.isCaseSense: - findOpt |= QTextDocument.FindCaseSensitively - if self.docSearch.isWholeWord: - findOpt |= QTextDocument.FindWholeWords - - searchFor = self.docSearch.getSearchObject() - wasFound = self.find(searchFor, findOpt) - if not wasFound: - if self.docSearch.doNextFile and not isBackward: - self.theParent.openNextDocument( - self.theHandle, wrapAround=self.docSearch.doLoop - ) - elif self.docSearch.doLoop: - theCursor = self.textCursor() - theCursor.movePosition( - QTextCursor.End if isBackward else QTextCursor.Start - ) - self.setTextCursor(theCursor) - wasFound = self.find(searchFor, findOpt) - - if wasFound: - theCursor = self.textCursor() - self.lastFind = (theCursor.selectionStart(), theCursor.selectionEnd()) - - return - - def _replaceNext(self): - """Searches for the next occurrence of the search bar text in - the document and replaces it with the replace text. Calls search - next automatically when done. - """ - if not self.docSearch.isVisible(): - # The search tool is not active, so we activate it. - self._beginSearch() - return - - theCursor = self.textCursor() - if not theCursor.hasSelection(): - # We have no text selected at all, so just make this a - # regular find next call. - self._findNext() - return - - if self.lastFind is None and theCursor.hasSelection(): - # If we have a selection but no search, it may have been the - # text we triggered the search with, in which case we search - # again from the beginning of that selection to make sure we - # have a valid result. - sPos = theCursor.selectionStart() - theCursor.clearSelection() - theCursor.setPosition(sPos) - self.setTextCursor(theCursor) - self._findNext() - theCursor = self.textCursor() - - if self.lastFind is None: - # In case the above didn't find a result, we give up here. - return - - searchFor = self.docSearch.getSearchText() - replWith = self.docSearch.getReplaceText() - - if self.docSearch.doMatchCap: - replWith = transferCase(theCursor.selectedText(), replWith) - - # Make sure the selected text was selected by an actual find - # call, and not the user. - try: - isFind = self.lastFind[0] == theCursor.selectionStart() - isFind &= self.lastFind[1] == theCursor.selectionEnd() - except Exception: - isFind = False - - if isFind: - theCursor.beginEditBlock() - theCursor.removeSelectedText() - theCursor.insertText(replWith) - theCursor.endEditBlock() - theCursor.setPosition(theCursor.selectionEnd()) - self.setTextCursor(theCursor) - logger.verbose("Replaced occurrence of '%s' with '%s' on line %d" % ( - searchFor, replWith, theCursor.blockNumber() - )) - else: - logger.error("The selected text is not a search result, skipping replace") - - self._findNext() - - return - def _setupSpellChecking(self): """Create the spell checking object based on the spellTool setting in config. @@ -2074,15 +2081,15 @@ class GuiDocEditSearch(QFrame): """ modKey = qApp.keyboardModifiers() if modKey == Qt.ShiftModifier: - self.docEditor.docAction(nwDocAction.GO_PREV) + self.docEditor.findNext(goBack=True) else: - self.docEditor.docAction(nwDocAction.GO_NEXT) + self.docEditor.findNext() return def _doReplace(self): """Call the replace action function for the document editor. """ - self.docEditor.docAction(nwDocAction.REPL_NEXT) + self.docEditor.replaceNext() return def _doToggleReplace(self, theState): diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py index 6d6267a9..1673387c 100644 --- a/nw/gui/mainmenu.py +++ b/nw/gui/mainmenu.py @@ -756,7 +756,7 @@ class GuiMainMenu(QMenuBar): self.aFind = QAction("Find", self) self.aFind.setStatusTip("Find text in document") self.aFind.setShortcut("Ctrl+F") - self.aFind.triggered.connect(lambda: self._docAction(nwDocAction.FIND)) + self.aFind.triggered.connect(lambda: self.theParent.docEditor.beginSearch()) self.srcMenu.addAction(self.aFind) # Search > Replace @@ -766,7 +766,7 @@ class GuiMainMenu(QMenuBar): self.aReplace.setShortcut("Ctrl+=") else: self.aReplace.setShortcut("Ctrl+H") - self.aReplace.triggered.connect(lambda: self._docAction(nwDocAction.REPLACE)) + self.aReplace.triggered.connect(lambda: self.theParent.docEditor.beginReplace()) self.srcMenu.addAction(self.aReplace) # Search > Find Next @@ -776,7 +776,7 @@ class GuiMainMenu(QMenuBar): self.aFindNext.setShortcuts(["Ctrl+G", "F3"]) else: self.aFindNext.setShortcuts(["F3", "Ctrl+G"]) - self.aFindNext.triggered.connect(lambda: self._docAction(nwDocAction.GO_NEXT)) + self.aFindNext.triggered.connect(lambda: self.theParent.docEditor.findNext()) self.srcMenu.addAction(self.aFindNext) # Search > Find Prev @@ -786,14 +786,14 @@ class GuiMainMenu(QMenuBar): self.aFindPrev.setShortcuts(["Ctrl+Shift+G", "Shift+F3"]) else: self.aFindPrev.setShortcuts(["Shift+F3", "Ctrl+Shift+G"]) - self.aFindPrev.triggered.connect(lambda: self._docAction(nwDocAction.GO_PREV)) + self.aFindPrev.triggered.connect(lambda: self.theParent.docEditor.findNext(goBack=True)) self.srcMenu.addAction(self.aFindPrev) # Search > Replace Next self.aReplaceNext = QAction("Replace Next", self) self.aReplaceNext.setStatusTip("Find and replace next occurrence text in document") self.aReplaceNext.setShortcut("Ctrl+Shift+1") - self.aReplaceNext.triggered.connect(lambda: self._docAction(nwDocAction.REPL_NEXT)) + self.aReplaceNext.triggered.connect(lambda: self.theParent.docEditor.replaceNext()) self.srcMenu.addAction(self.aReplaceNext) return diff --git a/nw/guimain.py b/nw/guimain.py index 6cb68cb3..055a4b00 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -757,14 +757,18 @@ class GuiMain(QMainWindow): return True def passDocumentAction(self, theAction): - """Pass on document action theAction to the document viewer if - it has focus, otherwise pass it to the document editor. + """Pass on document action to the document viewer if it has + focus, or pass it to the document editor if it or any of + its clid widgets have focus. If neither has focus, ignore the + action. """ if self.docViewer.hasFocus(): self.docViewer.docAction(theAction) - else: + elif self.docEditor.hasFocus(): self.docEditor.docAction(theAction) - return True + else: + logger.debug("Action cancelled as neither editor nor viewer has focus") + return ## # Tree Item Actions diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index b169e457..dfcf1f86 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -510,7 +510,7 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, nwLipsum): assert abs(nwGUI.docEditor.getCursorPosition() - 1127) < 3 # Toggle Replace - nwGUI.docEditor._beginReplace() + nwGUI.docEditor.beginReplace() # MonkeyPatch the focus cycle. We can't really test this very well, other than # check that the tabs aren't captured when the main editor has focus