Add context menu option to create note from tag (#1582)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
# ===========
|
# ===========
|
||||||
|
|||||||
Reference in New Issue
Block a user