diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 28225d8b..fc513485 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -1067,49 +1067,48 @@ class GuiDocEditor(QPlainTextEdit): @pyqtSlot("QPoint") def _openContextMenu(self, pos: QPoint) -> None: - """Triggered by right click to open the context menu. Also - triggered by the Ctrl+. shortcut. - """ + """Open the editor context menu at a given coordinate.""" uCursor = self.textCursor() pCursor = self.cursorForPosition(pos) pBlock = pCursor.block() ctxMenu = QMenu(self) + ctxMenu.setObjectName("ContextMenu") if pBlock.userState() == GuiDocHighlighter.BLOCK_TITLE: - aLabel = ctxMenu.addAction(self.tr("Set as Document Name")) - aLabel.triggered.connect(lambda: self._emitRenameItem(pBlock)) + action = ctxMenu.addAction(self.tr("Set as Document Name")) + action.triggered.connect(lambda: self._emitRenameItem(pBlock)) # Follow status = self._processTag(cursor=pCursor, follow=False) if status == nwTrinary.POSITIVE: - aTag = ctxMenu.addAction(self.tr("Follow Tag")) - aTag.triggered.connect(lambda: self._processTag(cursor=pCursor, follow=True)) + action = ctxMenu.addAction(self.tr("Follow Tag")) + action.triggered.connect(lambda: self._processTag(cursor=pCursor, follow=True)) ctxMenu.addSeparator() elif status == nwTrinary.NEGATIVE: - aTag = ctxMenu.addAction(self.tr("Create Note for Tag")) - aTag.triggered.connect(lambda: self._processTag(cursor=pCursor, create=True)) + action = ctxMenu.addAction(self.tr("Create Note for Tag")) + action.triggered.connect(lambda: self._processTag(cursor=pCursor, create=True)) ctxMenu.addSeparator() # Cut, Copy and Paste if uCursor.hasSelection(): - aCut = ctxMenu.addAction(self.tr("Cut")) - aCut.triggered.connect(lambda: self.docAction(nwDocAction.CUT)) - aCopy = ctxMenu.addAction(self.tr("Copy")) - aCopy.triggered.connect(lambda: self.docAction(nwDocAction.COPY)) + action = ctxMenu.addAction(self.tr("Cut")) + action.triggered.connect(lambda: self.docAction(nwDocAction.CUT)) + action = ctxMenu.addAction(self.tr("Copy")) + action.triggered.connect(lambda: self.docAction(nwDocAction.COPY)) - aPaste = ctxMenu.addAction(self.tr("Paste")) - aPaste.triggered.connect(lambda: self.docAction(nwDocAction.PASTE)) + action = ctxMenu.addAction(self.tr("Paste")) + action.triggered.connect(lambda: self.docAction(nwDocAction.PASTE)) ctxMenu.addSeparator() # Selections - aSAll = ctxMenu.addAction(self.tr("Select All")) - aSAll.triggered.connect(lambda: self.docAction(nwDocAction.SEL_ALL)) - aSWrd = ctxMenu.addAction(self.tr("Select Word")) - aSWrd.triggered.connect( + action = ctxMenu.addAction(self.tr("Select All")) + action.triggered.connect(lambda: self.docAction(nwDocAction.SEL_ALL)) + action = ctxMenu.addAction(self.tr("Select Word")) + action.triggered.connect( lambda: self._makePosSelection(QTextCursor.SelectionType.WordUnderCursor, pos) ) - aSPar = ctxMenu.addAction(self.tr("Select Paragraph")) - aSPar.triggered.connect(lambda: self._makePosSelection( + action = ctxMenu.addAction(self.tr("Select Paragraph")) + action.triggered.connect(lambda: self._makePosSelection( QTextCursor.SelectionType.BlockUnderCursor, pos) ) @@ -1128,16 +1127,16 @@ class GuiDocEditor(QPlainTextEdit): ctxMenu.addSeparator() ctxMenu.addAction(self.tr("Spelling Suggestion(s)")) for option in suggest[:15]: - aFix = ctxMenu.addAction(f"{nwUnicode.U_ENDASH} {option}") - aFix.triggered.connect( + action = ctxMenu.addAction(f"{nwUnicode.U_ENDASH} {option}") + action.triggered.connect( lambda _, option=option: self._correctWord(sCursor, option) ) else: ctxMenu.addAction("%s %s" % (nwUnicode.U_ENDASH, self.tr("No Suggestions"))) ctxMenu.addSeparator() - aAdd = ctxMenu.addAction(self.tr("Add Word to Dictionary")) - aAdd.triggered.connect(lambda: self._addWord(word, block)) + action = ctxMenu.addAction(self.tr("Add Word to Dictionary")) + action.triggered.connect(lambda: self._addWord(word, block)) # Execute the context menu ctxMenu.exec_(self.viewport().mapToGlobal(pos)) diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index c6464987..e9eca659 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -20,13 +20,14 @@ along with this program. If not, see . """ import pytest +from novelwriter.dialogs.editlabel import GuiEditLabel from tools import C, buildTestProject from mocked import causeOSError -from PyQt5.QtGui import QTextBlock, QTextCursor, QTextOption +from PyQt5.QtGui import QClipboard, QTextBlock, QTextCursor, QTextOption from PyQt5.QtCore import QThreadPool, Qt -from PyQt5.QtWidgets import QAction, qApp +from PyQt5.QtWidgets import QAction, QMenu, qApp from novelwriter import CONFIG, SHARED from novelwriter.enum import nwDocAction, nwDocInsert, nwItemLayout, nwTrinary, nwWidget @@ -209,6 +210,148 @@ def testGuiEditor_MetaData(qtbot, nwGUI, projPath, mockRnd): # END Test testGuiEditor_MetaData +@pytest.mark.gui +def testGuiEditor_ContextMenu(monkeypatch, qtbot, nwGUI, projPath, mockRnd): + """Test the editor context menu.""" + monkeypatch.setattr(QMenu, "exec_", lambda *a: None) + + buildTestProject(nwGUI, projPath) + assert nwGUI.openDocument(C.hSceneDoc) is True + docEditor = nwGUI.docEditor + sceneItem = SHARED.project.tree[C.hSceneDoc] + assert sceneItem is not None + + def getMenuForPos(pos: int, select: bool = False) -> QMenu | None: + nonlocal docEditor + cursor = docEditor.textCursor() + cursor.setPosition(pos) + if select: + cursor.select(QTextCursor.SelectionType.WordUnderCursor) + docEditor.setTextCursor(cursor) + docEditor._openContextMenu(docEditor.cursorRect().center()) + for obj in docEditor.children(): + if isinstance(obj, QMenu) and obj.objectName() == "ContextMenu": + return obj + return None + + docText = ( + "### A Scene\n\n" + "@pov: Jane\n" + "Some text ..." + ) + docEditor.setPlainText(docText) + assert docEditor.getText() == docText + + # Rename Item from Heading + ctxMenu = getMenuForPos(1) + assert ctxMenu is not None + actions = [x.text() for x in ctxMenu.actions() if x.text()] + assert actions == [ + "Set as Document Name", "Paste", + "Select All", "Select Word", "Select Paragraph" + ] + with monkeypatch.context() as mp: + mp.setattr(GuiEditLabel, "getLabel", lambda a, text: (text, True)) + assert sceneItem.itemName == "New Scene" + ctxMenu.actions()[0].trigger() + assert sceneItem.itemName == "A Scene" + ctxMenu.setObjectName("") + ctxMenu.deleteLater() + + # Create Character + ctxMenu = getMenuForPos(21) + assert ctxMenu is not None + actions = [x.text() for x in ctxMenu.actions() if x.text()] + assert actions == [ + "Create Note for Tag", "Paste", + "Select All", "Select Word", "Select Paragraph" + ] + ctxMenu.actions()[0].trigger() + janeItem = SHARED.project.tree["0000000000010"] + assert janeItem is not None + assert janeItem.itemName == "Jane" + ctxMenu.setObjectName("") + ctxMenu.deleteLater() + + # Follow Character Tag + ctxMenu = getMenuForPos(21) + assert ctxMenu is not None + actions = [x.text() for x in ctxMenu.actions() if x.text()] + assert actions == [ + "Follow Tag", "Paste", + "Select All", "Select Word", "Select Paragraph" + ] + ctxMenu.actions()[0].trigger() + assert nwGUI.docViewer.docHandle == "0000000000010" + ctxMenu.setObjectName("") + ctxMenu.deleteLater() + + # Select Word + ctxMenu = getMenuForPos(31) + assert ctxMenu is not None + actions = [x.text() for x in ctxMenu.actions() if x.text()] + assert actions == [ + "Paste", "Select All", "Select Word", "Select Paragraph" + ] + ctxMenu.actions()[3].trigger() + assert docEditor.textCursor().selectedText() == "text" + ctxMenu.setObjectName("") + ctxMenu.deleteLater() + + # Select Paragraph + ctxMenu = getMenuForPos(31) + assert ctxMenu is not None + actions = [x.text() for x in ctxMenu.actions() if x.text()] + assert actions == [ + "Paste", "Select All", "Select Word", "Select Paragraph" + ] + ctxMenu.actions()[4].trigger() + assert docEditor.textCursor().selectedText() == "Some text ..." + ctxMenu.setObjectName("") + ctxMenu.deleteLater() + + # Select All + ctxMenu = getMenuForPos(31) + assert ctxMenu is not None + actions = [x.text() for x in ctxMenu.actions() if x.text()] + assert actions == [ + "Paste", "Select All", "Select Word", "Select Paragraph" + ] + ctxMenu.actions()[2].trigger() + assert docEditor.textCursor().selectedText() == docEditor.document().toRawText() + ctxMenu.setObjectName("") + ctxMenu.deleteLater() + + # Copy Text + ctxMenu = getMenuForPos(31, True) + assert ctxMenu is not None + assert docEditor.textCursor().selectedText() == "text" + actions = [x.text() for x in ctxMenu.actions() if x.text()] + assert actions == [ + "Cut", "Copy", "Paste", "Select All", "Select Word", "Select Paragraph" + ] + qApp.clipboard().clear() + ctxMenu.actions()[1].trigger() + assert qApp.clipboard().text(QClipboard.Mode.Clipboard) == "text" + + # Cut Text + qApp.clipboard().clear() + ctxMenu.actions()[0].trigger() + assert qApp.clipboard().text(QClipboard.Mode.Clipboard) == "text" + assert "text" not in docEditor.getText() + + # Paste Text + ctxMenu.actions()[2].trigger() + assert docEditor.getText() == docText + + ctxMenu.setObjectName("") + ctxMenu.deleteLater() + + # qtbot.stop() + +# END Test testGuiEditor_ContextMenu + + @pytest.mark.gui def testGuiEditor_Actions(qtbot, nwGUI, projPath, ipsumText, mockRnd): """Test the document actions. This is not an extensive test of the