Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add context menu option to create note from tag #1582

Merged
merged 3 commits into from
Nov 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions novelwriter/enum.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 47 additions & 28 deletions novelwriter/gui/doceditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@
)

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.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.document import NWDocument
from novelwriter.gui.dochighlight import GuiDocHighlighter
Expand Down Expand Up @@ -149,17 +150,17 @@ def __init__(self, mainGui: GuiMain) -> None:
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)
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()
Expand Down Expand Up @@ -918,7 +919,7 @@ def mouseReleaseEvent(self, event: QMouseEvent) -> None:
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
Expand Down Expand Up @@ -974,10 +975,9 @@ def _docChange(self, pos: int, removed: int, added: int) -> None:
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)
Expand All @@ -994,6 +994,7 @@ def _insertCompletion(self, pos: int, length: int, text: str) -> None:
cursor.setPosition(pos, QTextCursor.MoveMode.MoveAnchor)
cursor.setPosition(pos + length, QTextCursor.MoveMode.KeepAnchor)
cursor.insertText(text)
self._completer.hide()
return

@pyqtSlot("QPoint")
Expand All @@ -1007,9 +1008,14 @@ def _openContextMenu(self, pos: QPoint) -> None:
ctxMenu = QMenu(self)

# 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.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()

# Cut, Copy and Paste
Expand Down Expand Up @@ -1690,7 +1696,8 @@ def _removeInParLineBreaks(self) -> None:
# 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
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
Expand All @@ -1702,41 +1709,53 @@ def _followTag(self, cursor: QTextCursor | None = None, loadTag: bool = True) ->

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 follow and exist:
logger.debug("Attempting to follow tag '%s'", tag)
self.loadDocumentTagRequest.emit(tag, nwDocMode.VIEW)
else:
logger.debug("Potential tag '%s'", tag)

return True

return False

def _openSpellContext(self) -> None:
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)):
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 nwTrinary.POSITIVE if exist else nwTrinary.NEGATIVE

return nwTrinary.UNKNOWN

def _openContextFromCursor(self) -> None:
"""Open the spell check context menu at the cursor."""
self._openContextMenu(self.cursorRect().center())
return
Expand Down
14 changes: 14 additions & 0 deletions novelwriter/gui/projtree.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ def __init__(self, mainGui: GuiMain) -> None:
self.getSelectedHandle = self.projTree.getSelectedHandle
self.setSelectedHandle = self.projTree.setSelectedHandle
self.changedSince = self.projTree.changedSince
self.createNewNote = self.projTree.createNewNote

return

Expand Down Expand Up @@ -570,6 +571,19 @@ def clearTree(self) -> None:
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:
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,
hLevel: int = 1, isNote: bool = False) -> bool:
"""Add new item to the tree, with a given itemType (and
Expand Down
34 changes: 22 additions & 12 deletions tests/test_gui/test_gui_doceditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_gui/test_gui_guimain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ===========
Expand Down