Skip to content

Commit

Permalink
Add an auto-completer for editor references (#1581)
Browse files Browse the repository at this point in the history
  • Loading branch information
vkbo authored Nov 6, 2023
2 parents 2a40913 + fd1eaa8 commit 1ab35b5
Show file tree
Hide file tree
Showing 4 changed files with 246 additions and 22 deletions.
10 changes: 10 additions & 0 deletions novelwriter/core/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,10 @@ def getTagSource(self, tagKey: str) -> tuple[str, str]:
sTitle = self._tagsIndex.tagHeading(tagKey)
return tHandle, sTitle

def getTags(self, itemClass: nwItemClass) -> list[str]:
"""Return all tags based on itemClass."""
return self._tagsIndex.filterTagNames(itemClass.name)

# END Class NWIndex


Expand Down Expand Up @@ -684,6 +688,12 @@ def tagClass(self, tagKey: str) -> str | None:
"""Get the class of a given tag."""
return self._tags.get(tagKey.lower(), {}).get("class", None)

def filterTagNames(self, className: str) -> list[str]:
"""Get a list of tag names for a given class."""
return [
x.get("name", "") for x in self._tags.values() if x.get("class", "") == className
]

##
# Pack/Unpack
##
Expand Down
117 changes: 110 additions & 7 deletions novelwriter/gui/doceditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ def __init__(self, mainGui: GuiMain) -> None:
self._typPadBefore = ""
self._typPadAfter = ""

# Completer
self._completer = MetaCompleter(self)
self._completer.complete.connect(self._insertCompletion)

# Create Custom Document
self._qDocument = GuiTextDocument(self)
self.setDocument(self._qDocument)
Expand Down Expand Up @@ -960,9 +964,36 @@ def _docChange(self, pos: int, removed: int, added: int) -> None:
if not self.wcTimerDoc.isActive():
self.wcTimerDoc.start()

if self._doReplace and added == 1:
self._docAutoReplace(self._qDocument.findBlock(pos))
block = self._qDocument.findBlock(pos)
if not block.isValid():
return

text = block.text()
if text.startswith("@"):
cursor = self.textCursor()
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()

elif self._doReplace and added == 1:
self._docAutoReplace(text)

return

@pyqtSlot(int, int, str)
def _insertCompletion(self, pos: int, length: int, text: str) -> None:
"""Insert choice from the completer menu."""
cursor = self.textCursor()
block = cursor.block()
if block.isValid():
pos += block.position()
cursor.setPosition(pos, QTextCursor.MoveMode.MoveAnchor)
cursor.setPosition(pos + length, QTextCursor.MoveMode.KeepAnchor)
cursor.insertText(text)
return

@pyqtSlot("QPoint")
Expand Down Expand Up @@ -1710,12 +1741,8 @@ def _openSpellContext(self) -> None:
self._openContextMenu(self.cursorRect().center())
return

def _docAutoReplace(self, block: QTextBlock) -> None:
def _docAutoReplace(self, text: str) -> None:
"""Auto-replace text elements based on main configuration."""
if not block.isValid():
return

text = block.text()
cursor = self.textCursor()
tPos = cursor.positionInBlock()
tLen = len(text)
Expand Down Expand Up @@ -1888,6 +1915,82 @@ def _allowAutoReplace(self, state: bool) -> None:
# END Class GuiDocEditor


class MetaCompleter(QMenu):
"""GuiWidget: Meta Completer Menu
This is a context menu with options populated from the user's
defined tags. It also helps to type the meta data keyword on a new
line starting with an @. The updateText function should be called on
every keystroke on a line starting with @.
"""

complete = pyqtSignal(int, int, str)

def __init__(self, parent: QWidget) -> None:
super().__init__(parent=parent)
return

def updateText(self, text: str, pos: int) -> bool:
"""Update the menu options based on the line of text."""
self.clear()
kw, sep, _ = text.partition(":")
if pos <= len(kw):
offset = 0
length = len(kw.rstrip())
suffix = "" if sep else ":"
options = list(filter(
lambda x: x.startswith(kw.rstrip()), nwKeyWords.VALID_KEYS
))
else:
status, tBits, tPos = SHARED.project.index.scanThis(text)
if not status:
return False
index = bisect.bisect_right(tPos, pos) - 1
lookup = tBits[index].lower() if index > 0 else ""
offset = tPos[index] if lookup else pos
length = len(lookup)
suffix = ""
options = list(filter(
lambda x: lookup in x.lower(), SHARED.project.index.getTags(
nwKeyWords.KEY_CLASS.get(kw.strip(), nwItemClass.NO_CLASS)
)
))[:15]

if not options:
return False

for value in sorted(options):
rep = value + suffix
action = self.addAction(value)
action.triggered.connect(lambda _, r=rep: self._emitComplete(offset, length, r))

return True

##
# Events
##

def keyPressEvent(self, event: QKeyEvent) -> None:
"""Capture keypresses and forward most of them to the editor."""
parent = self.parent()
if event.key() in (Qt.Key_Up, Qt.Key_Down, Qt.Key_Return, Qt.Key_Enter, Qt.Key_Escape):
super().keyPressEvent(event)
elif isinstance(parent, GuiDocEditor):
parent.keyPressEvent(event)
return

##
# Internal Functions
##

def _emitComplete(self, pos: int, length: int, value: str):
"""Emit the signal to indicate a selection has been made."""
self.complete.emit(pos, length, value)
return

# END Class MetaCompleter


# =============================================================================================== #
# The Off-GUI Thread Word Counter
# A runnable for the word counter to be run in the thread pool off the main GUI thread.
Expand Down
10 changes: 10 additions & 0 deletions tests/test_core/test_core_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,16 @@ def testCoreIndex_ScanThis(mockGUI):
assert theBits == ["@tag", "this", "and this"]
assert thePos == [0, 6, 12]

isValid, theBits, thePos = index.scanThis("@tag: this,, and this")
assert isValid is True
assert theBits == ["@tag", "this", "", "and this"]
assert thePos == [0, 6, 11, 13]

isValid, theBits, thePos = index.scanThis("@tag: this, , and this")
assert isValid is True
assert theBits == ["@tag", "this", "", "and this"]
assert thePos == [0, 6, 12, 14]

project.closeProject()

# END Test testCoreIndex_ScanThis
Expand Down
131 changes: 116 additions & 15 deletions tests/test_gui/test_gui_doceditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@

import pytest

from mocked import causeOSError
from tools import C, buildTestProject
from mocked import causeOSError

from PyQt5.QtCore import QThreadPool, Qt
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
from novelwriter.enum import nwDocAction, nwDocInsert, nwItemLayout, nwWidget
from novelwriter.constants import nwKeyWords, nwUnicode
from novelwriter.core.index import countWords
from novelwriter.gui.doceditor import GuiDocEditor
Expand Down Expand Up @@ -145,10 +145,10 @@ def testGuiEditor_SaveText(qtbot, monkeypatch, caplog, nwGUI, projPath, ipsumTex
assert "Could not save document." in caplog.text

# Change header level
assert SHARED.project.tree[C.hSceneDoc].itemLayout == nwItemLayout.DOCUMENT
assert SHARED.project.tree[C.hSceneDoc].itemLayout == nwItemLayout.DOCUMENT # type: ignore
nwGUI.docEditor.replaceText(longText[1:])
assert nwGUI.docEditor.saveText() is True
assert SHARED.project.tree[C.hSceneDoc].itemLayout == nwItemLayout.DOCUMENT
assert SHARED.project.tree[C.hSceneDoc].itemLayout == nwItemLayout.DOCUMENT # type: ignore

# Regular save
assert nwGUI.docEditor.saveText() is True
Expand Down Expand Up @@ -184,9 +184,9 @@ def testGuiEditor_MetaData(qtbot, nwGUI, projPath, mockRnd):
# Cursor Position
nwGUI.docEditor.setCursorPosition(10)
assert nwGUI.docEditor.getCursorPosition() == 10
assert SHARED.project.tree[C.hSceneDoc].cursorPos != 10
assert SHARED.project.tree[C.hSceneDoc].cursorPos != 10 # type: ignore
nwGUI.docEditor.saveCursorPosition()
assert SHARED.project.tree[C.hSceneDoc].cursorPos == 10
assert SHARED.project.tree[C.hSceneDoc].cursorPos == 10 # type: ignore

nwGUI.docEditor.setCursorLine(None)
assert nwGUI.docEditor.getCursorPosition() == 10
Expand Down Expand Up @@ -1041,14 +1041,14 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd):
assert nwGUI.openDocument(C.hSceneDoc) is True

# Create Scene
theText = "### A Scene\n\n@char: Jane, John\n\n" + ipsumText[0] + "\n\n"
nwGUI.docEditor.replaceText(theText)
text = "### A Scene\n\n@char: Jane, John\n\n" + ipsumText[0] + "\n\n"
nwGUI.docEditor.replaceText(text)

# Create Character
theText = "### Jane Doe\n\n@tag: Jane\n\n" + ipsumText[1] + "\n\n"
text = "### Jane Doe\n\n@tag: Jane\n\n" + ipsumText[1] + "\n\n"
cHandle = SHARED.project.newFile("Jane Doe", C.hCharRoot)
assert nwGUI.openDocument(cHandle) is True
nwGUI.docEditor.replaceText(theText)
nwGUI.docEditor.replaceText(text)
assert nwGUI.saveDocument() is True
assert nwGUI.projView.projTree.revealNewTreeItem(cHandle)
nwGUI.docEditor.updateTagHighLighting()
Expand Down Expand Up @@ -1092,6 +1092,107 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd):
# END Test testGuiEditor_Tags


@pytest.mark.gui
def testGuiEditor_Completer(qtbot, nwGUI, projPath, mockRnd):
"""Test the document editor meta completer functionality."""
buildTestProject(nwGUI, projPath)
assert nwGUI.openDocument(C.hSceneDoc) is True

# Create Character
text = (
"# Jane Doe\n\n"
"@tag: Jane\n\n"
"# John Doe\n\n"
"@tag: John\n\n"
)
cHandle = SHARED.project.newFile("People", C.hCharRoot)
assert nwGUI.openDocument(cHandle) is True
nwGUI.docEditor.replaceText(text)
assert nwGUI.saveDocument() is True
assert nwGUI.projView.projTree.revealNewTreeItem(cHandle)

docEditor = nwGUI.docEditor
docEditor.replaceText("")
completer = docEditor._completer

# Create Scene
nwGUI.switchFocus(nwWidget.EDITOR)
for c in "### Scene One":
qtbot.keyClick(docEditor, c, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key_Return, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key_Return, delay=KEY_DELAY)

# Type Keyword @
qtbot.keyClick(docEditor, "@", delay=KEY_DELAY)
assert len(completer.actions()) == len(nwKeyWords.VALID_KEYS)

# Type "c" to filer list to 2
qtbot.keyClick(docEditor, "c", delay=KEY_DELAY)
assert len(completer.actions()) == 2

# Type "q" to filer list to 0
qtbot.keyClick(docEditor, "q", delay=KEY_DELAY)
assert len(completer.actions()) == 0

# Delete character and go select @char
qtbot.keyClick(docEditor, Qt.Key_Backspace, delay=KEY_DELAY)
assert len(completer.actions()) == 2
completer.actions()[0].trigger()
assert docEditor.getText() == (
"### Scene One\n\n"
"@char:"
)

# The list of Characters should show up automatically
qtbot.keyClick(docEditor, " ", delay=KEY_DELAY)
assert [a.text() for a in completer.actions()] == ["Jane", "John"]

# Typing "q" should clear the list
qtbot.keyClick(docEditor, "q", delay=KEY_DELAY)
assert [a.text() for a in completer.actions()] == []

# Deleting it and typing "a", should leave "Jane"
qtbot.keyClick(docEditor, Qt.Key_Backspace, delay=KEY_DELAY)
qtbot.keyClick(docEditor, "a", delay=KEY_DELAY)
assert [a.text() for a in completer.actions()] == ["Jane"]

# Selecting "Jane" should insert it
completer.actions()[0].trigger()
qtbot.keyClick(docEditor, Qt.Key_Return, delay=KEY_DELAY)
assert docEditor.getText() == (
"### Scene One\n\n"
"@char: Jane\n"
)

# Start a new line with a nonsense keyword, which should be handled
for c in "@: ":
qtbot.keyClick(docEditor, c, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key_Backspace, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key_Backspace, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key_Backspace, delay=KEY_DELAY)

# Send keypresses to the completer object
qtbot.keyClick(docEditor, "@", delay=KEY_DELAY)
assert len(completer.actions()) == len(nwKeyWords.VALID_KEYS)
qtbot.keyClick(completer, "f", delay=KEY_DELAY)
qtbot.keyClick(completer, Qt.Key_Down, delay=KEY_DELAY)
qtbot.keyClick(completer, Qt.Key_Return, delay=KEY_DELAY)
qtbot.keyClick(completer, " ", delay=KEY_DELAY)
qtbot.keyClick(completer, "h", delay=KEY_DELAY)
qtbot.keyClick(completer, Qt.Key_Down, delay=KEY_DELAY)
qtbot.keyClick(completer, Qt.Key_Return, delay=KEY_DELAY)
qtbot.keyClick(completer, Qt.Key_Escape, delay=KEY_DELAY)
assert docEditor.getText() == (
"### Scene One\n\n"
"@char: Jane\n"
"@focus: John"
)

# qtbot.stop()

# END Test testGuiEditor_Completer


@pytest.mark.gui
def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, mockRnd):
"""Test saving text from the editor."""
Expand Down Expand Up @@ -1125,8 +1226,8 @@ def objectID(self):
assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)"

# Open a document and populate it
SHARED.project.tree[C.hSceneDoc]._initCount = 0 # Clear item's count
SHARED.project.tree[C.hSceneDoc]._wordCount = 0 # Clear item's count
SHARED.project.tree[C.hSceneDoc]._initCount = 0 # type: ignore
SHARED.project.tree[C.hSceneDoc]._wordCount = 0 # type: ignore
assert nwGUI.openDocument(C.hSceneDoc) is True

theText = "\n\n".join(ipsumText)
Expand All @@ -1150,9 +1251,9 @@ def objectID(self):

nwGUI.docEditor.wCounterDoc.run()
# nwGUI.docEditor._updateDocCounts(cC, wC, pC)
assert SHARED.project.tree[C.hSceneDoc]._charCount == cC
assert SHARED.project.tree[C.hSceneDoc]._wordCount == wC
assert SHARED.project.tree[C.hSceneDoc]._paraCount == pC
assert SHARED.project.tree[C.hSceneDoc]._charCount == cC # type: ignore
assert SHARED.project.tree[C.hSceneDoc]._wordCount == wC # type: ignore
assert SHARED.project.tree[C.hSceneDoc]._paraCount == pC # type: ignore
assert nwGUI.docEditor.docFooter.wordsText.text() == f"Words: {wC} (+{wC})"

# Select all text
Expand Down

0 comments on commit 1ab35b5

Please sign in to comment.