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) sTitle = self._tagsIndex.tagHeading(tagKey)
return tHandle, sTitle 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 # END Class NWIndex
@@ -684,6 +688,12 @@ class TagsIndex:
"""Get the class of a given tag.""" """Get the class of a given tag."""
return self._tags.get(tagKey.lower(), {}).get("class", None) 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 # Pack/Unpack
## ##
+110 -7
View File
@@ -117,6 +117,10 @@ class GuiDocEditor(QPlainTextEdit):
self._typPadBefore = "" self._typPadBefore = ""
self._typPadAfter = "" self._typPadAfter = ""
# Completer
self._completer = MetaCompleter(self)
self._completer.complete.connect(self._insertCompletion)
# Create Custom Document # Create Custom Document
self._qDocument = GuiTextDocument(self) self._qDocument = GuiTextDocument(self)
self.setDocument(self._qDocument) self.setDocument(self._qDocument)
@@ -960,9 +964,36 @@ class GuiDocEditor(QPlainTextEdit):
if not self.wcTimerDoc.isActive(): if not self.wcTimerDoc.isActive():
self.wcTimerDoc.start() self.wcTimerDoc.start()
if self._doReplace and added == 1: block = self._qDocument.findBlock(pos)
self._docAutoReplace(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 return
@pyqtSlot("QPoint") @pyqtSlot("QPoint")
@@ -1710,12 +1741,8 @@ class GuiDocEditor(QPlainTextEdit):
self._openContextMenu(self.cursorRect().center()) self._openContextMenu(self.cursorRect().center())
return return
def _docAutoReplace(self, block: QTextBlock) -> None: def _docAutoReplace(self, text: str) -> None:
"""Auto-replace text elements based on main configuration.""" """Auto-replace text elements based on main configuration."""
if not block.isValid():
return
text = block.text()
cursor = self.textCursor() cursor = self.textCursor()
tPos = cursor.positionInBlock() tPos = cursor.positionInBlock()
tLen = len(text) tLen = len(text)
@@ -1888,6 +1915,82 @@ class GuiDocEditor(QPlainTextEdit):
# END Class GuiDocEditor # 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 # The Off-GUI Thread Word Counter
# A runnable for the word counter to be run in the thread pool off the main GUI thread. # 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 theBits == ["@tag", "this", "and this"]
assert thePos == [0, 6, 12] 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() project.closeProject()
# END Test testCoreIndex_ScanThis # 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 import pytest
from mocked import causeOSError
from tools import C, buildTestProject from tools import C, buildTestProject
from mocked import causeOSError
from PyQt5.QtCore import QThreadPool, Qt from PyQt5.QtCore import QThreadPool, Qt
from PyQt5.QtGui import QTextBlock, QTextCursor, QTextOption 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 from novelwriter.enum import nwDocAction, nwDocInsert, nwItemLayout, 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
@@ -145,10 +145,10 @@ def testGuiEditor_SaveText(qtbot, monkeypatch, caplog, nwGUI, projPath, ipsumTex
assert "Could not save document." in caplog.text assert "Could not save document." in caplog.text
# Change header level # 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:]) nwGUI.docEditor.replaceText(longText[1:])
assert nwGUI.docEditor.saveText() is True 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 # Regular save
assert nwGUI.docEditor.saveText() is True assert nwGUI.docEditor.saveText() is True
@@ -184,9 +184,9 @@ def testGuiEditor_MetaData(qtbot, nwGUI, projPath, mockRnd):
# Cursor Position # Cursor Position
nwGUI.docEditor.setCursorPosition(10) nwGUI.docEditor.setCursorPosition(10)
assert nwGUI.docEditor.getCursorPosition() == 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() 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) nwGUI.docEditor.setCursorLine(None)
assert nwGUI.docEditor.getCursorPosition() == 10 assert nwGUI.docEditor.getCursorPosition() == 10
@@ -1041,14 +1041,14 @@ 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
theText = "### A Scene\n\n@char: Jane, John\n\n" + ipsumText[0] + "\n\n" text = "### A Scene\n\n@char: Jane, John\n\n" + ipsumText[0] + "\n\n"
nwGUI.docEditor.replaceText(theText) nwGUI.docEditor.replaceText(text)
# Create Character # 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) cHandle = SHARED.project.newFile("Jane Doe", C.hCharRoot)
assert nwGUI.openDocument(cHandle) is True assert nwGUI.openDocument(cHandle) is True
nwGUI.docEditor.replaceText(theText) nwGUI.docEditor.replaceText(text)
assert nwGUI.saveDocument() is True assert nwGUI.saveDocument() is True
assert nwGUI.projView.projTree.revealNewTreeItem(cHandle) assert nwGUI.projView.projTree.revealNewTreeItem(cHandle)
nwGUI.docEditor.updateTagHighLighting() nwGUI.docEditor.updateTagHighLighting()
@@ -1092,6 +1092,107 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd):
# END Test testGuiEditor_Tags # 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 @pytest.mark.gui
def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, mockRnd): def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, mockRnd):
"""Test saving text from the editor.""" """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)" assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)"
# Open a document and populate it # Open a document and populate it
SHARED.project.tree[C.hSceneDoc]._initCount = 0 # Clear item's count SHARED.project.tree[C.hSceneDoc]._initCount = 0 # type: ignore
SHARED.project.tree[C.hSceneDoc]._wordCount = 0 # Clear item's count SHARED.project.tree[C.hSceneDoc]._wordCount = 0 # type: ignore
assert nwGUI.openDocument(C.hSceneDoc) is True assert nwGUI.openDocument(C.hSceneDoc) is True
theText = "\n\n".join(ipsumText) theText = "\n\n".join(ipsumText)
@@ -1150,9 +1251,9 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, m
nwGUI.docEditor.wCounterDoc.run() nwGUI.docEditor.wCounterDoc.run()
# nwGUI.docEditor._updateDocCounts(cC, wC, pC) # nwGUI.docEditor._updateDocCounts(cC, wC, pC)
assert SHARED.project.tree[C.hSceneDoc]._charCount == cC assert SHARED.project.tree[C.hSceneDoc]._charCount == cC # type: ignore
assert SHARED.project.tree[C.hSceneDoc]._wordCount == wC assert SHARED.project.tree[C.hSceneDoc]._wordCount == wC # type: ignore
assert SHARED.project.tree[C.hSceneDoc]._paraCount == pC assert SHARED.project.tree[C.hSceneDoc]._paraCount == pC # type: ignore
assert nwGUI.docEditor.docFooter.wordsText.text() == f"Words: {wC} (+{wC})" assert nwGUI.docEditor.docFooter.wordsText.text() == f"Words: {wC} (+{wC})"
# Select all text # Select all text