Add an auto-completer for editor references (#1581)
This commit is contained in:
@@ -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
|
||||
##
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user