From d4f7d8eb67e1c543726447edf71da276b7f82feb Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 6 Sep 2020 13:24:33 +0200 Subject: [PATCH 1/5] Added cut/copy/paste to document context menu --- nw/gui/doceditor.py | 75 ++++++++++++++++++++++++++++----------------- 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/nw/gui/doceditor.py b/nw/gui/doceditor.py index dc2b632f..c949ad85 100644 --- a/nw/gui/doceditor.py +++ b/nw/gui/doceditor.py @@ -735,38 +735,57 @@ class GuiDocEditor(QTextEdit): if not self.spellCheck: return - theCursor = self.cursorForPosition(thePos) - theCursor.select(QTextCursor.WordUnderCursor) + userCursor = self.textCursor() + userSelection = userCursor.hasSelection() - theWord = theCursor.selectedText().strip().strip(self.nonWord) - if theWord == "": - return + mnuContext = QMenu() - logger.verbose("Looking up '%s' in the dictionary" % theWord) - if self.theDict.checkWord(theWord): - return + if userSelection: + mnuCut = QAction("Cut", mnuContext) + mnuCut.triggered.connect(lambda: self.docAction(nwDocAction.CUT)) + mnuContext.addAction(mnuCut) - mnuSuggest = QMenu() - mnuHead = QAction("Spelling Suggestion(s)", mnuSuggest) - mnuSuggest.addAction(mnuHead) - mnuSuggest.addSeparator() - theSuggest = self.theDict.suggestWords(theWord) - if len(theSuggest) > 0: - for aWord in theSuggest: - mnuWord = QAction(aWord, mnuSuggest) - mnuWord.triggered.connect( - lambda thePos, aWord=aWord : self._correctWord(theCursor, aWord) - ) - mnuSuggest.addAction(mnuWord) - mnuSuggest.addSeparator() - mnuAdd = QAction("Add Word to Dictionary", mnuSuggest) - mnuAdd.triggered.connect(lambda thePos : self._addWord(theCursor)) - mnuSuggest.addAction(mnuAdd) - else: - mnuHead = QAction("No Suggestions", mnuSuggest) - mnuSuggest.addAction(mnuHead) + mnuCopy = QAction("Copy", mnuContext) + mnuCopy.triggered.connect(lambda: self.docAction(nwDocAction.COPY)) + mnuContext.addAction(mnuCopy) - mnuSuggest.exec_(self.viewport().mapToGlobal(thePos)) + mnuPaste = QAction("Paste", mnuContext) + mnuPaste.triggered.connect(lambda: self.docAction(nwDocAction.PASTE)) + mnuContext.addAction(mnuPaste) + + # Check if we should look up the spelling + posCursor = self.cursorForPosition(thePos) + posCursor.select(QTextCursor.WordUnderCursor) + theWord = posCursor.selectedText().strip().strip(self.nonWord) + spellCheck = theWord != "" + + if spellCheck: + logger.verbose("Looking up '%s' in the dictionary" % theWord) + spellCheck &= not self.theDict.checkWord(theWord) + + if spellCheck: + mnuContext.addSeparator() + mnuHead = QAction("Spelling Suggestion(s)", mnuContext) + mnuContext.addAction(mnuHead) + + theSuggest = self.theDict.suggestWords(theWord) + if len(theSuggest) > 0: + for aWord in theSuggest: + mnuWord = QAction(aWord, mnuContext) + mnuWord.triggered.connect( + lambda thePos, aWord=aWord : self._correctWord(posCursor, aWord) + ) + mnuContext.addAction(mnuWord) + mnuContext.addSeparator() + mnuAdd = QAction("Add Word to Dictionary", mnuContext) + mnuAdd.triggered.connect(lambda thePos : self._addWord(posCursor)) + mnuContext.addAction(mnuAdd) + + else: + mnuHead = QAction("No Suggestions", mnuContext) + mnuContext.addAction(mnuHead) + + mnuContext.exec_(self.viewport().mapToGlobal(thePos)) return From a6a65fb7d29afd7b44c5cfc7ca3cbf77e82e16bf Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 6 Sep 2020 13:46:00 +0200 Subject: [PATCH 2/5] Added selection functions to the context menu --- nw/constants/constants.py | 2 ++ nw/gui/doceditor.py | 56 ++++++++++++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/nw/constants/constants.py b/nw/constants/constants.py index 3bfc9125..8e2a7d66 100644 --- a/nw/constants/constants.py +++ b/nw/constants/constants.py @@ -245,6 +245,7 @@ class nwUnicode: ## Symbols U_CHECK = "\u2714" # Heavy check mark U_MULT = "\u2715" # Multiplication x + U_BULL = "\u2022" # List bullet ## Arrows U_UTRI = "\u25b2" # Up-pointing triangle @@ -294,6 +295,7 @@ class nwUnicode: ## Symbols H_CHECK = "✔" H_MULT = "✕" + H_BULL = "•" ## Arrows H_UTRI = "▲" diff --git a/nw/gui/doceditor.py b/nw/gui/doceditor.py index c949ad85..5ebdf19e 100644 --- a/nw/gui/doceditor.py +++ b/nw/gui/doceditor.py @@ -732,14 +732,14 @@ class GuiDocEditor(QTextEdit): """Triggered by right click to open the context menu. Also triggered by the Ctrl+. shortcut. """ - if not self.spellCheck: - return - userCursor = self.textCursor() userSelection = userCursor.hasSelection() mnuContext = QMenu() + # Cut, Copy and Paste + # =================== + if userSelection: mnuCut = QAction("Cut", mnuContext) mnuCut.triggered.connect(lambda: self.docAction(nwDocAction.CUT)) @@ -753,11 +753,37 @@ class GuiDocEditor(QTextEdit): mnuPaste.triggered.connect(lambda: self.docAction(nwDocAction.PASTE)) mnuContext.addAction(mnuPaste) - # Check if we should look up the spelling - posCursor = self.cursorForPosition(thePos) - posCursor.select(QTextCursor.WordUnderCursor) - theWord = posCursor.selectedText().strip().strip(self.nonWord) - spellCheck = theWord != "" + mnuContext.addSeparator() + + # Selections + # ========== + + mnuSelAll = QAction("Select All", mnuContext) + mnuSelAll.triggered.connect(lambda: self.docAction(nwDocAction.SEL_ALL)) + mnuContext.addAction(mnuSelAll) + + mnuSelWord = QAction("Select Word", mnuContext) + mnuSelWord.triggered.connect( + lambda: self._makePosSelection(QTextCursor.WordUnderCursor, thePos) + ) + mnuContext.addAction(mnuSelWord) + + mnuSelPara = QAction("Select Paragraph", mnuContext) + mnuSelPara.triggered.connect( + lambda: self._makePosSelection(QTextCursor.BlockUnderCursor, thePos) + ) + mnuContext.addAction(mnuSelPara) + + # Spell Checking + # ============== + + spellCheck = self.spellCheck + + if spellCheck: + posCursor = self.cursorForPosition(thePos) + posCursor.select(QTextCursor.WordUnderCursor) + theWord = posCursor.selectedText().strip().strip(self.nonWord) + spellCheck &= theWord != "" if spellCheck: logger.verbose("Looking up '%s' in the dictionary" % theWord) @@ -771,7 +797,7 @@ class GuiDocEditor(QTextEdit): theSuggest = self.theDict.suggestWords(theWord) if len(theSuggest) > 0: for aWord in theSuggest: - mnuWord = QAction(aWord, mnuContext) + mnuWord = QAction("%s %s" % (nwUnicode.U_ENDASH, aWord), mnuContext) mnuWord.triggered.connect( lambda thePos, aWord=aWord : self._correctWord(posCursor, aWord) ) @@ -785,6 +811,7 @@ class GuiDocEditor(QTextEdit): mnuHead = QAction("No Suggestions", mnuContext) mnuContext.addAction(mnuHead) + # Open the context menu mnuContext.exec_(self.viewport().mapToGlobal(thePos)) return @@ -1215,7 +1242,7 @@ class GuiDocEditor(QTextEdit): return def _makeSelection(self, selMode): - """Wrapper function to select a word based on a selection mode. + """Wrapper function to select text based on a selection mode. """ theCursor = self.textCursor() theCursor.clearSelection() @@ -1235,6 +1262,15 @@ class GuiDocEditor(QTextEdit): return + def _makePosSelection(self, selMode, thePos): + """Wrapper function to select text based on selection mode, but + first move cursor to given position. + """ + theCursor = self.cursorForPosition(thePos) + self.setTextCursor(theCursor) + self._makeSelection(selMode) + return + def _beginSearch(self): """Sets the selected text as the search text for the search bar. """ From af34bb4f380ca89f9e37a0587f0e7aaf29a71fa0 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 6 Sep 2020 14:01:08 +0200 Subject: [PATCH 3/5] Added custom context menu also to document viewer --- nw/gui/docviewer.py | 77 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/nw/gui/docviewer.py b/nw/gui/docviewer.py index c3378806..0c8c51b1 100644 --- a/nw/gui/docviewer.py +++ b/nw/gui/docviewer.py @@ -31,12 +31,13 @@ import logging import nw -from PyQt5.QtCore import Qt, QUrl, QSize +from PyQt5.QtCore import Qt, QUrl, QSize, pyqtSlot from PyQt5.QtGui import ( QTextOption, QFont, QPalette, QColor, QTextCursor, QIcon ) from PyQt5.QtWidgets import ( - QTextBrowser, QWidget, QScrollArea, QLabel, QHBoxLayout, QToolButton + QTextBrowser, QWidget, QScrollArea, QLabel, QHBoxLayout, QToolButton, + QAction, QMenu ) from nw.core import ToHtml @@ -77,6 +78,10 @@ class GuiDocViewer(QTextBrowser): self.anchorClicked.connect(self._linkClicked) self.setFocusPolicy(Qt.StrongFocus) + # Context Menu + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self._openContextMenu) + logger.debug("GuiDocViewer initialisation complete") return @@ -308,6 +313,7 @@ class GuiDocViewer(QTextBrowser): # Slots ## + @pyqtSlot("QUrl") def _linkClicked(self, theURL): """Slot for a link in the document being clicked. """ @@ -319,6 +325,49 @@ class GuiDocViewer(QTextBrowser): self.loadFromTag(theBits[1]) return + @pyqtSlot("QPoint") + def _openContextMenu(self, thePos): + """Triggered by right click to open the context menu. + """ + userCursor = self.textCursor() + userSelection = userCursor.hasSelection() + + mnuContext = QMenu() + + # Cut, Copy and Paste + # =================== + + if userSelection: + mnuCopy = QAction("Copy", mnuContext) + mnuCopy.triggered.connect(lambda: self.docAction(nwDocAction.COPY)) + mnuContext.addAction(mnuCopy) + + mnuContext.addSeparator() + + # Selections + # ========== + + mnuSelAll = QAction("Select All", mnuContext) + mnuSelAll.triggered.connect(lambda: self.docAction(nwDocAction.SEL_ALL)) + mnuContext.addAction(mnuSelAll) + + mnuSelWord = QAction("Select Word", mnuContext) + mnuSelWord.triggered.connect( + lambda: self._makePosSelection(QTextCursor.WordUnderCursor, thePos) + ) + mnuContext.addAction(mnuSelWord) + + mnuSelPara = QAction("Select Paragraph", mnuContext) + mnuSelPara.triggered.connect( + lambda: self._makePosSelection(QTextCursor.BlockUnderCursor, thePos) + ) + mnuContext.addAction(mnuSelPara) + + # Open the context menu + mnuContext.exec_(self.viewport().mapToGlobal(thePos)) + + return + ## # Events ## @@ -335,13 +384,33 @@ class GuiDocViewer(QTextBrowser): ## def _makeSelection(self, selMode): - """Wrapper function for making a selection based on a specific - selection mode. + """Wrapper function to select text based on a selection mode. """ theCursor = self.textCursor() theCursor.clearSelection() theCursor.select(selMode) + + if selMode == QTextCursor.BlockUnderCursor: + # This selection mode also selects the preceding oaragraph + # separator, which we want to avoid. + posS = theCursor.selectionStart() + posE = theCursor.selectionEnd() + selTxt = theCursor.selectedText() + if selTxt.startswith(nwUnicode.U_PSEP): + theCursor.setPosition(posS+1, QTextCursor.MoveAnchor) + theCursor.setPosition(posE, QTextCursor.KeepAnchor) + self.setTextCursor(theCursor) + + return + + def _makePosSelection(self, selMode, thePos): + """Wrapper function to select text based on selection mode, but + first move cursor to given position. + """ + theCursor = self.cursorForPosition(thePos) + self.setTextCursor(theCursor) + self._makeSelection(selMode) return def _makeStyleSheet(self): From 70d96f3fdbdbb8e9fee6fad261bbf175e05c1fd9 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 6 Sep 2020 14:30:59 +0200 Subject: [PATCH 4/5] Added test coverage of context menus and selection functions --- tests/test_gui.py | 53 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/test_gui.py b/tests/test_gui.py index 5e02b21c..db5fcde1 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -10,6 +10,7 @@ from nwtools import cmpFiles from os import path from PyQt5.QtCore import Qt, QPoint +from PyQt5.QtGui import QTextCursor from PyQt5.QtWidgets import QAction, QDialogButtonBox, QTreeWidgetItem from nw.gui import ( @@ -1110,6 +1111,58 @@ def testDocAction(qtbot, nwLipsum, nwTemp): assert nwGUI.docEditor.getText()[27:74] == cleanText qtbot.wait(stepDelay) + # Editor Context Menu + theCursor = nwGUI.docEditor.textCursor() + theCursor.setPosition(100) + nwGUI.docEditor.setTextCursor(theCursor) + theRect = nwGUI.docEditor.cursorRect() + + nwGUI.docEditor._openContextMenu(theRect.bottomRight()) + qtbot.mouseClick(nwGUI.docEditor, Qt.LeftButton, pos=theRect.topLeft()) + + nwGUI.docEditor._makePosSelection(QTextCursor.WordUnderCursor, theRect.center()) + theCursor = nwGUI.docEditor.textCursor() + assert theCursor.selectedText() == "imperdiet" + + nwGUI.docEditor._makePosSelection(QTextCursor.BlockUnderCursor, theRect.center()) + theCursor = nwGUI.docEditor.textCursor() + assert theCursor.selectedText() == ( + "Pellentesque nec erat ut nulla posuere commodo. Curabitur nisi augue, imperdiet et porta " + "imperdiet, efficitur id leo. Cras finibus arcu at nibh commodo congue. Proin suscipit " + "placerat condimentum. Aenean ante enim, cursus id lorem a, blandit venenatis nibh. " + "Maecenas suscipit porta elit, sit amet porta felis porttitor eu. Sed a dui nibh. " + "Phasellus sed faucibus dui. Pellentesque felis nulla, ultrices non efficitur quis, " + "rutrum id mi. Mauris tempus auctor nisl, in bibendum enim pellentesque sit amet. Proin " + "nunc lacus, imperdiet nec posuere ac, interdum non lectus." + ) + + # Viewer Context Menu + assert nwGUI.viewDocument("4c4f28287af27") + + theCursor = nwGUI.docViewer.textCursor() + theCursor.setPosition(100) + nwGUI.docViewer.setTextCursor(theCursor) + theRect = nwGUI.docViewer.cursorRect() + + nwGUI.docViewer._openContextMenu(theRect.bottomRight()) + qtbot.mouseClick(nwGUI.docViewer, Qt.LeftButton, pos=theRect.topLeft()) + + nwGUI.docViewer._makePosSelection(QTextCursor.WordUnderCursor, theRect.center()) + theCursor = nwGUI.docViewer.textCursor() + assert theCursor.selectedText() == "imperdiet" + + nwGUI.docEditor._makePosSelection(QTextCursor.BlockUnderCursor, theRect.center()) + theCursor = nwGUI.docEditor.textCursor() + assert theCursor.selectedText() == ( + "Pellentesque nec erat ut nulla posuere commodo. Curabitur nisi augue, imperdiet et porta " + "imperdiet, efficitur id leo. Cras finibus arcu at nibh commodo congue. Proin suscipit " + "placerat condimentum. Aenean ante enim, cursus id lorem a, blandit venenatis nibh. " + "Maecenas suscipit porta elit, sit amet porta felis porttitor eu. Sed a dui nibh. " + "Phasellus sed faucibus dui. Pellentesque felis nulla, ultrices non efficitur quis, " + "rutrum id mi. Mauris tempus auctor nisl, in bibendum enim pellentesque sit amet. Proin " + "nunc lacus, imperdiet nec posuere ac, interdum non lectus." + ) + # qtbot.stopForInteraction() nwGUI.closeMain() From 2c2fe125eaa00066d4655cd8e9dcf283fe027da4 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 6 Sep 2020 14:44:05 +0200 Subject: [PATCH 5/5] Minor fix to test that sometimes fails in a place it shouldn't --- tests/test_gui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_gui.py b/tests/test_gui.py index db5fcde1..bdb684ff 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -398,7 +398,7 @@ def testProjectEditor(qtbot, nwFuncTemp, nwTempGUI, nwRef, nwTemp): testFile = path.join(nwTempGUI, "2_nwProject.nwx") refFile = path.join(nwRef, "gui", "2_nwProject.nwx") copyfile(projFile, testFile) - assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) + assert cmpFiles(testFile, refFile, [2, 8, 9, 10]) # qtbot.stopForInteraction() nwGUI.closeMain()