Add context menu option to create note from tag (#1582)

This commit is contained in:
Veronica Berglyd Olsen
2023-11-06 22:48:06 +01:00
committed by GitHub
5 changed files with 90 additions and 38 deletions
+9
View File
@@ -61,6 +61,15 @@ class nwItemLayout(Enum):
# END Enum nwItemLayout # END Enum nwItemLayout
class nwTrinary(Enum):
NEGATIVE = -1
UNKNOWN = 0
POSITIVE = 1
# END Enum nwTrinary
class nwDocMode(Enum): class nwDocMode(Enum):
VIEW = 0 VIEW = 0
+44 -25
View File
@@ -51,9 +51,10 @@ from PyQt5.QtWidgets import (
) )
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwItemClass from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwItemClass, nwTrinary
from novelwriter.common import minmax, transferCase from novelwriter.common import minmax, transferCase
from novelwriter.constants import nwKeyWords, nwUnicode from novelwriter.constants import nwKeyWords, nwLabels, nwUnicode, trConst
from novelwriter.core.item import NWItem
from novelwriter.core.index import countWords from novelwriter.core.index import countWords
from novelwriter.core.document import NWDocument from novelwriter.core.document import NWDocument
from novelwriter.gui.dochighlight import GuiDocHighlighter from novelwriter.gui.dochighlight import GuiDocHighlighter
@@ -149,17 +150,17 @@ class GuiDocEditor(QPlainTextEdit):
self.keyContext = QShortcut(self) self.keyContext = QShortcut(self)
self.keyContext.setKey("Ctrl+.") self.keyContext.setKey("Ctrl+.")
self.keyContext.setContext(Qt.WidgetShortcut) self.keyContext.setContext(Qt.WidgetShortcut)
self.keyContext.activated.connect(self._openSpellContext) self.keyContext.activated.connect(self._openContextFromCursor)
self.followTag1 = QShortcut(self) self.followTag1 = QShortcut(self)
self.followTag1.setKey(Qt.Key_Return | Qt.ControlModifier) self.followTag1.setKey(Qt.Key_Return | Qt.ControlModifier)
self.followTag1.setContext(Qt.WidgetShortcut) self.followTag1.setContext(Qt.WidgetShortcut)
self.followTag1.activated.connect(self._followTag) self.followTag1.activated.connect(self._processTag)
self.followTag2 = QShortcut(self) self.followTag2 = QShortcut(self)
self.followTag2.setKey(Qt.Key_Enter | Qt.ControlModifier) self.followTag2.setKey(Qt.Key_Enter | Qt.ControlModifier)
self.followTag2.setContext(Qt.WidgetShortcut) self.followTag2.setContext(Qt.WidgetShortcut)
self.followTag2.activated.connect(self._followTag) self.followTag2.activated.connect(self._processTag)
# Set Up Document Word Counter # Set Up Document Word Counter
self.wcTimerDoc = QTimer() self.wcTimerDoc = QTimer()
@@ -918,7 +919,7 @@ class GuiDocEditor(QPlainTextEdit):
follow tag function. follow tag function.
""" """
if qApp.keyboardModifiers() == Qt.ControlModifier: if qApp.keyboardModifiers() == Qt.ControlModifier:
self._followTag(self.cursorForPosition(event.pos())) self._processTag(self.cursorForPosition(event.pos()))
super().mouseReleaseEvent(event) super().mouseReleaseEvent(event)
self.docFooter.updateLineCount() self.docFooter.updateLineCount()
return return
@@ -974,10 +975,9 @@ class GuiDocEditor(QPlainTextEdit):
bPos = cursor.positionInBlock() bPos = cursor.positionInBlock()
if bPos > 0: if bPos > 0:
show = self._completer.updateText(text, bPos) show = self._completer.updateText(text, bPos)
if not self._completer.isVisible() and show: point = self.cursorRect().bottomRight()
point = self.cursorRect().bottomRight() self._completer.move(self.viewport().mapToGlobal(point))
self._completer.move(self.viewport().mapToGlobal(point)) self._completer.setVisible(show)
self._completer.show()
elif self._doReplace and added == 1: elif self._doReplace and added == 1:
self._docAutoReplace(text) self._docAutoReplace(text)
@@ -994,6 +994,7 @@ class GuiDocEditor(QPlainTextEdit):
cursor.setPosition(pos, QTextCursor.MoveMode.MoveAnchor) cursor.setPosition(pos, QTextCursor.MoveMode.MoveAnchor)
cursor.setPosition(pos + length, QTextCursor.MoveMode.KeepAnchor) cursor.setPosition(pos + length, QTextCursor.MoveMode.KeepAnchor)
cursor.insertText(text) cursor.insertText(text)
self._completer.hide()
return return
@pyqtSlot("QPoint") @pyqtSlot("QPoint")
@@ -1007,9 +1008,14 @@ class GuiDocEditor(QPlainTextEdit):
ctxMenu = QMenu(self) ctxMenu = QMenu(self)
# Follow # Follow
if self._followTag(cursor=pCursor, loadTag=False): status = self._processTag(cursor=pCursor, follow=False)
if status == nwTrinary.POSITIVE:
aTag = ctxMenu.addAction(self.tr("Follow Tag")) aTag = ctxMenu.addAction(self.tr("Follow Tag"))
aTag.triggered.connect(lambda: self._followTag(cursor=pCursor)) 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._processTag(cursor=pCursor, create=True))
ctxMenu.addSeparator() ctxMenu.addSeparator()
# Cut, Copy and Paste # Cut, Copy and Paste
@@ -1690,7 +1696,8 @@ class GuiDocEditor(QPlainTextEdit):
# Internal Functions # Internal Functions
## ##
def _followTag(self, cursor: QTextCursor | None = None, loadTag: bool = True) -> bool: 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 """Activated by Ctrl+Enter. Checks that we're in a block
starting with '@'. We then find the tag under the cursor and 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 check that it is not the tag itself. If all this is fine, we
@@ -1702,41 +1709,53 @@ class GuiDocEditor(QPlainTextEdit):
block = cursor.block() block = cursor.block()
text = block.text() text = block.text()
if len(text) == 0: 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) isGood, tBits, tPos = SHARED.project.index.scanThis(text)
if not isGood: if not isGood:
return False return nwTrinary.UNKNOWN
tag = "" tag = ""
exist = False
cPos = cursor.selectionStart() - block.position() 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: if cPos >= sPos:
# The cursor is between the start of two tags # The cursor is between the start of two tags
if cPos <= sPos + len(sTag): if cPos <= sPos + len(sTag):
# The cursor is inside or at the edge of the tag # The cursor is inside or at the edge of the tag
tag = sTag tag = sTag
exist = sExist
break break
if not tag or tag.startswith("@"): if not tag or tag.startswith("@"):
# The keyword cannot be looked up, so we ignore that # The keyword cannot be looked up, so we ignore that
return False return nwTrinary.UNKNOWN
if loadTag: if follow and exist:
logger.debug("Attempting to follow tag '%s'", tag) logger.debug("Attempting to follow tag '%s'", tag)
self.loadDocumentTagRequest.emit(tag, nwDocMode.VIEW) self.loadDocumentTagRequest.emit(tag, nwDocMode.VIEW)
else: elif create and not exist:
logger.debug("Potential tag '%s'", tag) 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.""" """Open the spell check context menu at the cursor."""
self._openContextMenu(self.cursorRect().center()) self._openContextMenu(self.cursorRect().center())
return return
+14
View File
@@ -147,6 +147,7 @@ class GuiProjectView(QWidget):
self.getSelectedHandle = self.projTree.getSelectedHandle self.getSelectedHandle = self.projTree.getSelectedHandle
self.setSelectedHandle = self.projTree.setSelectedHandle self.setSelectedHandle = self.projTree.setSelectedHandle
self.changedSince = self.projTree.changedSince self.changedSince = self.projTree.changedSince
self.createNewNote = self.projTree.createNewNote
return return
@@ -570,6 +571,19 @@ class GuiProjectTree(QTreeWidget):
self._timeChanged = 0.0 self._timeChanged = 0.0
return 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:
SHARED.project.writeNewFile(tHandle, 1, False, f"@tag: {tag}\n\n")
self.revealNewTreeItem(tHandle, wordCount=True)
return True
return False
def newTreeItem(self, itemType: nwItemType, itemClass: nwItemClass | None = None, def newTreeItem(self, itemType: nwItemType, itemClass: nwItemClass | None = None,
hLevel: int = 1, isNote: bool = False) -> bool: hLevel: int = 1, isNote: bool = False) -> bool:
"""Add new item to the tree, with a given itemType (and """Add new item to the tree, with a given itemType (and
+22 -12
View File
@@ -29,7 +29,7 @@ from PyQt5.QtGui import QTextBlock, QTextCursor, QTextOption
from PyQt5.QtWidgets import QAction, qApp from PyQt5.QtWidgets import QAction, qApp
from novelwriter import CONFIG, SHARED 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.constants import nwKeyWords, nwUnicode
from novelwriter.core.index import countWords from novelwriter.core.index import countWords
from novelwriter.gui.doceditor import GuiDocEditor 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 assert nwGUI.openDocument(C.hSceneDoc) is True
# Create Scene # 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) nwGUI.docEditor.replaceText(text)
# Create Character # Create Character
@@ -1059,34 +1059,44 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd):
# Empty Block # Empty Block
nwGUI.docEditor.setCursorLine(2) nwGUI.docEditor.setCursorLine(2)
assert nwGUI.docEditor._followTag() is False assert nwGUI.docEditor._processTag() is nwTrinary.UNKNOWN
# Not On Tag # Not On Tag
nwGUI.docEditor.setCursorLine(1) nwGUI.docEditor.setCursorLine(1)
assert nwGUI.docEditor._followTag() is False assert nwGUI.docEditor._processTag() is nwTrinary.UNKNOWN
# On Tag Keyword # On Tag Keyword
nwGUI.docEditor.setCursorPosition(15) nwGUI.docEditor.setCursorPosition(15)
assert nwGUI.docEditor._followTag() is False assert nwGUI.docEditor._processTag() is nwTrinary.UNKNOWN
# On Unknown Tag
nwGUI.docEditor.setCursorPosition(28)
assert nwGUI.docEditor._followTag() is True
assert nwGUI.docViewer._docHandle is None
# On Known Tag, No Follow # On Known Tag, No Follow
nwGUI.docEditor.setCursorPosition(22) 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 assert nwGUI.docViewer._docHandle is None
# On Known Tag, Follow # On Known Tag, Follow
nwGUI.docEditor.setCursorPosition(22) nwGUI.docEditor.setCursorPosition(22)
assert nwGUI.docViewer._docHandle is None 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.docViewer._docHandle == cHandle
assert nwGUI.closeDocViewer() is True assert nwGUI.closeDocViewer() is True
assert nwGUI.docViewer._docHandle is None 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() # qtbot.stop()
# END Test testGuiEditor_Tags # END Test testGuiEditor_Tags
+1 -1
View File
@@ -530,7 +530,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
mp.setattr(QMenu, "exec_", lambda *a: None) mp.setattr(QMenu, "exec_", lambda *a: None)
docEditor.setCursorPosition(errPos) docEditor.setCursorPosition(errPos)
docEditor._openSpellContext() docEditor._openContextFromCursor()
# Check Files # Check Files
# =========== # ===========