From f17e1f38dea55987659ab20d0ff87dc9ad9c84b1 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Mon, 6 Nov 2023 22:25:28 +0100 Subject: [PATCH 1/3] Add option to create note from tag, and improve auto-completer a bit --- novelwriter/enum.py | 9 +++++ novelwriter/gui/doceditor.py | 62 +++++++++++++++++++----------- novelwriter/gui/projtree.py | 15 ++++++++ tests/test_gui/test_gui_guimain.py | 2 +- 4 files changed, 65 insertions(+), 23 deletions(-) diff --git a/novelwriter/enum.py b/novelwriter/enum.py index 36546778..86849af3 100644 --- a/novelwriter/enum.py +++ b/novelwriter/enum.py @@ -61,6 +61,15 @@ class nwItemLayout(Enum): # END Enum nwItemLayout +class nwTrinary(Enum): + + NEGATIVE = -1 + UNKNOWN = 0 + POSITIVE = 1 + +# END Enum nwTrinary + + class nwDocMode(Enum): VIEW = 0 diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index da344f51..7b1b7867 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -51,9 +51,10 @@ from PyQt5.QtWidgets import ( ) from novelwriter import CONFIG, SHARED -from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwItemClass +from novelwriter.core.item import NWItem +from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwItemClass, nwTrinary from novelwriter.common import minmax, transferCase -from novelwriter.constants import nwKeyWords, nwUnicode +from novelwriter.constants import nwKeyWords, nwLabels, nwUnicode, trConst from novelwriter.core.index import countWords from novelwriter.core.document import NWDocument from novelwriter.gui.dochighlight import GuiDocHighlighter @@ -149,7 +150,7 @@ class GuiDocEditor(QPlainTextEdit): self.keyContext = QShortcut(self) self.keyContext.setKey("Ctrl+.") self.keyContext.setContext(Qt.WidgetShortcut) - self.keyContext.activated.connect(self._openSpellContext) + self.keyContext.activated.connect(self._openContextFromCursor) self.followTag1 = QShortcut(self) self.followTag1.setKey(Qt.Key_Return | Qt.ControlModifier) @@ -974,10 +975,9 @@ class GuiDocEditor(QPlainTextEdit): bPos = cursor.positionInBlock() if bPos > 0: show = self._completer.updateText(text, bPos) - if not self._completer.isVisible() and show: - point = self.cursorRect().bottomRight() - self._completer.move(self.viewport().mapToGlobal(point)) - self._completer.show() + point = self.cursorRect().bottomRight() + self._completer.move(self.viewport().mapToGlobal(point)) + self._completer.setVisible(show) elif self._doReplace and added == 1: self._docAutoReplace(text) @@ -994,6 +994,7 @@ class GuiDocEditor(QPlainTextEdit): cursor.setPosition(pos, QTextCursor.MoveMode.MoveAnchor) cursor.setPosition(pos + length, QTextCursor.MoveMode.KeepAnchor) cursor.insertText(text) + self._completer.hide() return @pyqtSlot("QPoint") @@ -1007,9 +1008,14 @@ class GuiDocEditor(QPlainTextEdit): ctxMenu = QMenu(self) # Follow - if self._followTag(cursor=pCursor, loadTag=False): + status = self._followTag(cursor=pCursor, process=False) + if status == nwTrinary.POSITIVE: aTag = ctxMenu.addAction(self.tr("Follow Tag")) - aTag.triggered.connect(lambda: self._followTag(cursor=pCursor)) + aTag.triggered.connect(lambda: self._followTag(cursor=pCursor, process=True)) + ctxMenu.addSeparator() + elif status == nwTrinary.NEGATIVE: + aTag = ctxMenu.addAction(self.tr("Create Note for Tag")) + aTag.triggered.connect(lambda: self._followTag(cursor=pCursor, process=True)) ctxMenu.addSeparator() # Cut, Copy and Paste @@ -1690,7 +1696,7 @@ class GuiDocEditor(QPlainTextEdit): # Internal Functions ## - def _followTag(self, cursor: QTextCursor | None = None, loadTag: bool = True) -> bool: + def _followTag(self, cursor: QTextCursor | None = None, process: bool = True) -> nwTrinary: """Activated by Ctrl+Enter. Checks that we're in a block starting with '@'. We then find the tag under the cursor and check that it is not the tag itself. If all this is fine, we @@ -1702,41 +1708,53 @@ class GuiDocEditor(QPlainTextEdit): block = cursor.block() text = block.text() - if len(text) == 0: - return False + return nwTrinary.UNKNOWN - if text.startswith("@"): + if text.startswith("@") and isinstance(self._nwItem, NWItem): isGood, tBits, tPos = SHARED.project.index.scanThis(text) if not isGood: - return False + return nwTrinary.UNKNOWN tag = "" + exist = False cPos = cursor.selectionStart() - block.position() - for sTag, sPos in zip(reversed(tBits), reversed(tPos)): + tExist = SHARED.project.index.checkThese(tBits, self._nwItem) + for sTag, sPos, sExist in zip(reversed(tBits), reversed(tPos), reversed(tExist)): if cPos >= sPos: # The cursor is between the start of two tags if cPos <= sPos + len(sTag): # The cursor is inside or at the edge of the tag tag = sTag + exist = sExist break if not tag or tag.startswith("@"): # The keyword cannot be looked up, so we ignore that - return False + return nwTrinary.UNKNOWN - if loadTag: + if process and exist: logger.debug("Attempting to follow tag '%s'", tag) self.loadDocumentTagRequest.emit(tag, nwDocMode.VIEW) - else: - logger.debug("Potential tag '%s'", tag) + elif process and not exist: + if SHARED.question(self.tr( + "Do you want to create a new project note for the tag '{0}'?" + ).format(tag)): + itemClass = nwKeyWords.KEY_CLASS.get(tBits[0], nwItemClass.NO_CLASS) + if SHARED.mainGui.projView.createNewNote(tag, itemClass): + self._qDocument.syntaxHighlighter.rehighlightBlock(block) + else: + SHARED.error(self.tr( + "Could not create note in a root folder for '{0}'. " + "If one doesn't exist, you must create one first." + ).format(trConst(nwLabels.CLASS_NAME[itemClass]))) - return True + return nwTrinary.POSITIVE if exist else nwTrinary.NEGATIVE - return False + return nwTrinary.UNKNOWN - def _openSpellContext(self) -> None: + def _openContextFromCursor(self) -> None: """Open the spell check context menu at the cursor.""" self._openContextMenu(self.cursorRect().center()) return diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 929f37c6..ef7c97d2 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -147,6 +147,7 @@ class GuiProjectView(QWidget): self.getSelectedHandle = self.projTree.getSelectedHandle self.setSelectedHandle = self.projTree.setSelectedHandle self.changedSince = self.projTree.changedSince + self.createNewNote = self.projTree.createNewNote return @@ -570,6 +571,20 @@ class GuiProjectTree(QTreeWidget): self._timeChanged = 0.0 return + def createNewNote(self, tag: str, itemClass: nwItemClass | None) -> bool: + """Create a new note. This function is used by the document + editor to create note files for unknown tags. + """ + rHandle = SHARED.project.tree.findRoot(itemClass) + if rHandle: + tHandle = SHARED.project.newFile(tag, rHandle) + if tHandle: + text = f"# {tag}\n\n@tag: {tag}\n\n" + SHARED.project.writeNewFile(tHandle, 1, False, text) + self.revealNewTreeItem(tHandle, wordCount=True) + return True + return False + def newTreeItem(self, itemType: nwItemType, itemClass: nwItemClass | None = None, hLevel: int = 1, isNote: bool = False) -> bool: """Add new item to the tree, with a given itemType (and diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index 30a17c5b..74e0e352 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -530,7 +530,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): with monkeypatch.context() as mp: mp.setattr(QMenu, "exec_", lambda *a: None) docEditor.setCursorPosition(errPos) - docEditor._openSpellContext() + docEditor._openContextFromCursor() # Check Files # =========== From ccd31226334c4fe8d003a185afe94f577198cd54 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Mon, 6 Nov 2023 22:42:02 +0100 Subject: [PATCH 2/3] Make some improvements and update tests --- novelwriter/gui/doceditor.py | 19 ++++++++-------- novelwriter/gui/projtree.py | 3 +-- tests/test_gui/test_gui_doceditor.py | 34 ++++++++++++++++++---------- 3 files changed, 33 insertions(+), 23 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 7b1b7867..ceb59dfd 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -155,12 +155,12 @@ class GuiDocEditor(QPlainTextEdit): self.followTag1 = QShortcut(self) self.followTag1.setKey(Qt.Key_Return | Qt.ControlModifier) self.followTag1.setContext(Qt.WidgetShortcut) - self.followTag1.activated.connect(self._followTag) + self.followTag1.activated.connect(self._processTag) self.followTag2 = QShortcut(self) self.followTag2.setKey(Qt.Key_Enter | Qt.ControlModifier) self.followTag2.setContext(Qt.WidgetShortcut) - self.followTag2.activated.connect(self._followTag) + self.followTag2.activated.connect(self._processTag) # Set Up Document Word Counter self.wcTimerDoc = QTimer() @@ -919,7 +919,7 @@ class GuiDocEditor(QPlainTextEdit): follow tag function. """ if qApp.keyboardModifiers() == Qt.ControlModifier: - self._followTag(self.cursorForPosition(event.pos())) + self._processTag(self.cursorForPosition(event.pos())) super().mouseReleaseEvent(event) self.docFooter.updateLineCount() return @@ -1008,14 +1008,14 @@ class GuiDocEditor(QPlainTextEdit): ctxMenu = QMenu(self) # Follow - status = self._followTag(cursor=pCursor, process=False) + status = self._processTag(cursor=pCursor, follow=False) if status == nwTrinary.POSITIVE: aTag = ctxMenu.addAction(self.tr("Follow Tag")) - aTag.triggered.connect(lambda: self._followTag(cursor=pCursor, process=True)) + aTag.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._followTag(cursor=pCursor, process=True)) + aTag.triggered.connect(lambda: self._processTag(cursor=pCursor, create=True)) ctxMenu.addSeparator() # Cut, Copy and Paste @@ -1696,7 +1696,8 @@ class GuiDocEditor(QPlainTextEdit): # Internal Functions ## - def _followTag(self, cursor: QTextCursor | None = None, process: bool = True) -> nwTrinary: + def _processTag(self, cursor: QTextCursor | None = None, + follow: bool = True, create: bool = False) -> nwTrinary: """Activated by Ctrl+Enter. Checks that we're in a block starting with '@'. We then find the tag under the cursor and check that it is not the tag itself. If all this is fine, we @@ -1734,10 +1735,10 @@ class GuiDocEditor(QPlainTextEdit): # The keyword cannot be looked up, so we ignore that return nwTrinary.UNKNOWN - if process and exist: + if follow and exist: logger.debug("Attempting to follow tag '%s'", tag) self.loadDocumentTagRequest.emit(tag, nwDocMode.VIEW) - elif process and not exist: + elif create and not exist: if SHARED.question(self.tr( "Do you want to create a new project note for the tag '{0}'?" ).format(tag)): diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index ef7c97d2..787f5671 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -579,8 +579,7 @@ class GuiProjectTree(QTreeWidget): if rHandle: tHandle = SHARED.project.newFile(tag, rHandle) if tHandle: - text = f"# {tag}\n\n@tag: {tag}\n\n" - SHARED.project.writeNewFile(tHandle, 1, False, text) + SHARED.project.writeNewFile(tHandle, 1, False, f"@tag: {tag}\n\n") self.revealNewTreeItem(tHandle, wordCount=True) return True return False diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index 7bcfc92c..9a28e5c2 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -29,7 +29,7 @@ from PyQt5.QtGui import QTextBlock, QTextCursor, QTextOption from PyQt5.QtWidgets import QAction, qApp from novelwriter import CONFIG, SHARED -from novelwriter.enum import nwDocAction, nwDocInsert, nwItemLayout, nwWidget +from novelwriter.enum import nwDocAction, nwDocInsert, nwItemLayout, nwTrinary, nwWidget from novelwriter.constants import nwKeyWords, nwUnicode from novelwriter.core.index import countWords from novelwriter.gui.doceditor import GuiDocEditor @@ -1041,7 +1041,7 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd): assert nwGUI.openDocument(C.hSceneDoc) is True # Create Scene - text = "### A Scene\n\n@char: Jane, John\n\n" + ipsumText[0] + "\n\n" + text = "### A Scene\n\n@char: Jane, John\n\n@object: Gun\n\n@:\n\n" + ipsumText[0] + "\n\n" nwGUI.docEditor.replaceText(text) # Create Character @@ -1059,34 +1059,44 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd): # Empty Block nwGUI.docEditor.setCursorLine(2) - assert nwGUI.docEditor._followTag() is False + assert nwGUI.docEditor._processTag() is nwTrinary.UNKNOWN # Not On Tag nwGUI.docEditor.setCursorLine(1) - assert nwGUI.docEditor._followTag() is False + assert nwGUI.docEditor._processTag() is nwTrinary.UNKNOWN # On Tag Keyword nwGUI.docEditor.setCursorPosition(15) - assert nwGUI.docEditor._followTag() is False - - # On Unknown Tag - nwGUI.docEditor.setCursorPosition(28) - assert nwGUI.docEditor._followTag() is True - assert nwGUI.docViewer._docHandle is None + assert nwGUI.docEditor._processTag() is nwTrinary.UNKNOWN # On Known Tag, No Follow nwGUI.docEditor.setCursorPosition(22) - assert nwGUI.docEditor._followTag(loadTag=False) is True + assert nwGUI.docEditor._processTag(follow=False) is nwTrinary.POSITIVE assert nwGUI.docViewer._docHandle is None # On Known Tag, Follow nwGUI.docEditor.setCursorPosition(22) assert nwGUI.docViewer._docHandle is None - assert nwGUI.docEditor._followTag(loadTag=True) is True + assert nwGUI.docEditor._processTag(follow=True) is nwTrinary.POSITIVE assert nwGUI.docViewer._docHandle == cHandle assert nwGUI.closeDocViewer() is True assert nwGUI.docViewer._docHandle is None + # On Unknown Tag, Create It + assert "0000000000011" not in SHARED.project.tree + nwGUI.docEditor.setCursorPosition(28) + assert nwGUI.docEditor._processTag(create=True) is nwTrinary.NEGATIVE + assert "0000000000011" in SHARED.project.tree + + # On Unknown Tag, Missing Root + assert "0000000000012" not in SHARED.project.tree + nwGUI.docEditor.setCursorPosition(42) + assert nwGUI.docEditor._processTag(create=True) is nwTrinary.NEGATIVE + assert "0000000000012" not in SHARED.project.tree + + nwGUI.docEditor.setCursorPosition(47) + assert nwGUI.docEditor._processTag() is nwTrinary.UNKNOWN + # qtbot.stop() # END Test testGuiEditor_Tags From eb5a98f4822426edc77e36e3d73141414c68dd18 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Mon, 6 Nov 2023 22:45:37 +0100 Subject: [PATCH 3/3] Sort imports --- novelwriter/gui/doceditor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index ceb59dfd..4d2e4556 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -51,10 +51,10 @@ from PyQt5.QtWidgets import ( ) from novelwriter import CONFIG, SHARED -from novelwriter.core.item import NWItem from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwItemClass, nwTrinary from novelwriter.common import minmax, transferCase from novelwriter.constants import nwKeyWords, nwLabels, nwUnicode, trConst +from novelwriter.core.item import NWItem from novelwriter.core.index import countWords from novelwriter.core.document import NWDocument from novelwriter.gui.dochighlight import GuiDocHighlighter