diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 62f3b4d4e..e4210dd24 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -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 @@ -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 ## diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 93053f200..da344f516 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -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) @@ -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") @@ -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) @@ -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. diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index 03b497dad..773b0e799 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -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 diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index faa750275..7bcfc92c4 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -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 @@ -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 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) @@ -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