Add an auto-completer for editor references (#1581)

This commit is contained in:
Veronica Berglyd Olsen
2023-11-06 19:35:46 +01:00
committed by GitHub
4 changed files with 246 additions and 22 deletions
+10
View File
@@ -621,6 +621,10 @@ class NWIndex:
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
@@ -684,6 +688,12 @@ class TagsIndex:
"""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
##
+110 -7
View File
@@ -117,6 +117,10 @@ class GuiDocEditor(QPlainTextEdit):
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)
@@ -960,9 +964,36 @@ class GuiDocEditor(QPlainTextEdit):
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")
@@ -1710,12 +1741,8 @@ class GuiDocEditor(QPlainTextEdit):
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)
@@ -1888,6 +1915,82 @@ class GuiDocEditor(QPlainTextEdit):
# 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.
+10
View File
@@ -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
+116 -15
View File
@@ -21,15 +21,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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
@@ -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
@@ -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
@@ -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()
@@ -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."""
@@ -1125,8 +1226,8 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, m
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)
@@ -1150,9 +1251,9 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, m
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