Add story notes and comment auto-complete (#2346)

This commit is contained in:
Veronica Berglyd Olsen
2025-05-18 22:54:34 +02:00
committed by GitHub
15 changed files with 183 additions and 31 deletions
@@ -4,6 +4,7 @@
"Footnotes": "Footnotes",
"Comment": "Comment",
"Story Structure": "Story Structure",
"Note": "Note",
"Notes": "Notes",
"Tag": "Tag",
"Point of View": "Point of View",
+2
View File
@@ -79,6 +79,7 @@ SETTINGS_TEMPLATE: dict[str, tuple[type, T_BuildValue]] = {
"text.includeSynopsis": (bool, False),
"text.includeComments": (bool, False),
"text.includeStory": (bool, False),
"text.includeNotes": (bool, False),
"text.includeKeywords": (bool, False),
"text.includeBodyText": (bool, True),
"text.ignoredKeywords": (str, ""),
@@ -146,6 +147,7 @@ SETTINGS_LABELS = {
"text.includeSynopsis": QT_TRANSLATE_NOOP("Builds", "Include Synopsis"),
"text.includeComments": QT_TRANSLATE_NOOP("Builds", "Include Comments"),
"text.includeStory": QT_TRANSLATE_NOOP("Builds", "Include Story Structure"),
"text.includeNotes": QT_TRANSLATE_NOOP("Builds", "Include Manuscript Notes"),
"text.includeKeywords": QT_TRANSLATE_NOOP("Builds", "Include Keywords"),
"text.includeBodyText": QT_TRANSLATE_NOOP("Builds", "Include Body Text"),
"text.ignoredKeywords": QT_TRANSLATE_NOOP("Builds", "Ignore These Keywords"),
+1
View File
@@ -317,6 +317,7 @@ class NWBuildDocument:
bldObj.setCommentType(nwComment.SYNOPSIS, self._build.getBool("text.includeSynopsis"))
bldObj.setCommentType(nwComment.SHORT, self._build.getBool("text.includeSynopsis"))
bldObj.setCommentType(nwComment.STORY, self._build.getBool("text.includeStory"))
bldObj.setCommentType(nwComment.NOTE, self._build.getBool("text.includeNotes"))
if isinstance(bldObj, ToHtml):
bldObj.setStyles(self._build.getBool("html.addStyles"))
+10 -1
View File
@@ -618,6 +618,10 @@ class Index:
"""Return all story structure keys."""
return self._itemIndex.allStoryKeys()
def getNoteKeys(self) -> set[str]:
"""Return all note comment keys."""
return self._itemIndex.allNoteKeys()
def novelStructure(
self, rootHandle: str | None = None, activeOnly: bool = True
) -> Iterable[tuple[str, str, str, IndexHeading]]:
@@ -920,11 +924,12 @@ class IndexCache:
which provides lookup capabilities and caching for shared data.
"""
__slots__ = ("story", "tags")
__slots__ = ("note", "story", "tags")
def __init__(self, tagsIndex: TagsIndex) -> None:
self.tags: TagsIndex = tagsIndex
self.story: set[str] = set()
self.note: set[str] = set()
return
@@ -979,6 +984,10 @@ class ItemIndex:
"""Return all story structure keys."""
return self._cache.story.copy()
def allNoteKeys(self) -> set[str]:
"""Return all note comment keys."""
return self._cache.note.copy()
def allItemTags(self, tHandle: str) -> list[str]:
"""Get all tags set for headings of an item."""
if tHandle in self._items:
+4 -1
View File
@@ -316,6 +316,9 @@ class IndexHeading:
case "story" if key:
self._cache.story.add(key)
self._comments[f"story.{key}"] = str(text)
case "note" if key:
self._cache.note.add(key)
self._comments[f"note.{key}"] = str(text)
return
def setTag(self, tag: str) -> None:
@@ -395,7 +398,7 @@ class IndexHeading:
self.addReference(tag, keyword)
else:
raise ValueError("Heading reference contains an invalid keyword")
elif key == "summary" or key.startswith("story"):
elif key == "summary" or key.startswith(("story", "note")):
comment, _, kind = str(key).partition(".")
self.setComment(comment, compact(kind), str(entry))
else:
+2 -1
View File
@@ -614,7 +614,8 @@ class Tokenizer(ABC):
tStyle |= BlockFmt.JUSTIFY
if cStyle in (
nwComment.SYNOPSIS, nwComment.SHORT, nwComment.PLAIN, nwComment.STORY
nwComment.SYNOPSIS, nwComment.SHORT, nwComment.PLAIN,
nwComment.STORY, nwComment.NOTE,
):
bStyle = COMMENT_STYLE[cStyle]
tLine, tFmt = self._formatComment(bStyle, cKey, cText)
+69 -21
View File
@@ -3,15 +3,16 @@ novelWriter GUI Document Editor
=================================
File History:
Created: 2018-09-29 [0.0.1] GuiDocEditor
Created: 2019-04-22 [0.0.1] BackgroundWordCounter
Created: 2019-09-29 [0.2.1] GuiDocEditSearch
Created: 2020-04-25 [0.4.5] GuiDocEditHeader
Rewritten: 2020-06-15 [0.9] GuiDocEditSearch
Created: 2020-06-27 [0.10] GuiDocEditFooter
Rewritten: 2020-10-07 [1.0b3] BackgroundWordCounter
Created: 2023-11-06 [2.2b1] MetaCompleter
Created: 2023-11-07 [2.2b1] GuiDocToolBar
Created: 2018-09-29 [0.0.1] GuiDocEditor
Created: 2019-04-22 [0.0.1] BackgroundWordCounter
Created: 2019-09-29 [0.2.1] GuiDocEditSearch
Created: 2020-04-25 [0.4.5] GuiDocEditHeader
Rewritten: 2020-06-15 [0.9] GuiDocEditSearch
Created: 2020-06-27 [0.10] GuiDocEditFooter
Rewritten: 2020-10-07 [1.0b3] BackgroundWordCounter
Created: 2023-11-06 [2.2b1] MetaCompleter
Created: 2023-11-07 [2.2b1] GuiDocToolBar
Extended: 2025-05-18 [2.7rc1] CommandCompleter
This file is a part of novelWriter
Copyright (C) 2018 Veronica Berglyd Olsen and novelWriter contributors
@@ -149,7 +150,7 @@ class GuiDocEditor(QPlainTextEdit):
self._autoReplace = TextAutoReplace()
# Completer
self._completer = MetaCompleter(self)
self._completer = CommandCompleter(self)
self._completer.complete.connect(self._insertCompletion)
# Create Custom Document
@@ -1079,13 +1080,16 @@ class GuiDocEditor(QPlainTextEdit):
if (block := self._qDocument.findBlock(pos)).isValid():
text = block.text()
if text.startswith("@") and added + removed == 1:
if text and text[0] in "@%" and added + removed == 1:
# Only run on single character changes, or it will trigger
# at unwanted times when other changes are made to the document
cursor = self.textCursor()
bPos = cursor.positionInBlock()
if bPos > 0 and (viewport := self.viewport()):
show = self._completer.updateText(text, bPos)
if text[0] == "@":
show = self._completer.updateMetaText(text, bPos)
else:
show = self._completer.updateCommentText(text, bPos)
point = self.cursorRect().bottomRight()
self._completer.move(viewport.mapToGlobal(point))
self._completer.setVisible(show)
@@ -2073,13 +2077,13 @@ class GuiDocEditor(QPlainTextEdit):
return
class MetaCompleter(QMenu):
"""GuiWidget: Meta Completer Menu
class CommandCompleter(QMenu):
"""GuiWidget: Command 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 @.
defined tags and keys. It also helps to type the meta data keyword
on a new line starting with @ or %. The update functions should be
called on every keystroke on a line starting with @ or %.
"""
complete = pyqtSignal(int, int, str)
@@ -2088,7 +2092,7 @@ class MetaCompleter(QMenu):
super().__init__(parent=parent)
return
def updateText(self, text: str, pos: int) -> bool:
def updateMetaText(self, text: str, pos: int) -> bool:
"""Update the menu options based on the line of text."""
self.clear()
kw, sep, _ = text.partition(":")
@@ -2096,7 +2100,7 @@ class MetaCompleter(QMenu):
offset = 0
length = len(kw.rstrip())
suffix = "" if sep else ":"
options = list(filter(
options = sorted(filter(
lambda x: x.startswith(kw.rstrip()), nwKeyWords.VALID_KEYS
))
else:
@@ -2108,7 +2112,7 @@ class MetaCompleter(QMenu):
offset = tPos[index] if lookup else pos
length = len(lookup)
suffix = ""
options = list(filter(
options = sorted(filter(
lambda x: lookup in x.lower(), SHARED.project.index.getClassTags(
nwKeyWords.KEY_CLASS.get(kw.strip())
)
@@ -2117,13 +2121,57 @@ class MetaCompleter(QMenu):
if not options:
return False
for value in sorted(options):
for value in options:
rep = value + suffix
action = qtAddAction(self, value)
action.triggered.connect(qtLambda(self._emitComplete, offset, length, rep))
return True
def updateCommentText(self, text: str, pos: int) -> bool:
"""Update the menu options based on the line of text."""
self.clear()
cmd, sep, _ = text.partition(":")
if pos <= len(cmd):
clean = text[1:].lstrip()[:6].lower()
if clean[:6] == "story.":
pre, _, key = cmd.partition(".")
offset = len(pre) + 1
length = len(key)
suffix = "" if sep else ": "
options = sorted(filter(
lambda x: x.startswith(key.rstrip()),
SHARED.project.index.getStoryKeys(),
))
elif clean[:5] == "note.":
pre, _, key = cmd.partition(".")
offset = len(pre) + 1
length = len(key)
suffix = "" if sep else ": "
options = sorted(filter(
lambda x: x.startswith(key.rstrip()),
SHARED.project.index.getNoteKeys(),
))
elif pos < 12:
offset = 0
length = len(cmd.rstrip())
suffix = ""
options = list(filter(
lambda x: x.startswith(cmd.rstrip()),
["%Synopsis: ", "%Short: ", "%Story", "%Note"],
))
else:
return False
if options:
for value in options:
rep = value + suffix
action = qtAddAction(self, rep.rstrip(":. "))
action.triggered.connect(qtLambda(self._emitComplete, offset, length, rep))
return True
return False
##
# Events
##
+7
View File
@@ -751,9 +751,13 @@ class GuiOutlineTree(QTreeWidget):
def _dumpNovelData(self, rootHandle: str | None) -> list[list[str | int]]:
"""Dump all novel data into a table."""
sLabel = SHARED.project.localLookup("Story Structure")
nLabel = SHARED.project.localLookup("Note")
sKeys = sorted(SHARED.project.index.getStoryKeys())
nKeys = sorted(SHARED.project.index.getNoteKeys())
sMatch = [f"story.{k}" for k in sKeys]
nMatch = [f"note.{k}" for k in nKeys]
sHeaders = [f"{sLabel} ({k})" for k in sKeys]
nHeaders = [f"{nLabel} ({k})" for k in nKeys]
data: list[list[str | int]] = [[
"H",
@@ -777,6 +781,7 @@ class GuiOutlineTree(QTreeWidget):
trConst(nwLabels.OUTLINE_COLS[nwOutline.MENTION]),
trConst(nwLabels.OUTLINE_COLS[nwOutline.SYNOP]),
*sHeaders,
*nHeaders,
]]
for _, tHandle, sTitle, novIdx in SHARED.project.index.novelStructure(
@@ -786,6 +791,7 @@ class GuiOutlineTree(QTreeWidget):
refs = SHARED.project.index.getReferences(tHandle, sTitle)
comments = dict(novIdx.comments.items())
story = [comments.get(k, "") for k in sMatch]
notes = [comments.get(k, "") for k in nMatch]
data.append([
novIdx.level,
novIdx.title,
@@ -808,6 +814,7 @@ class GuiOutlineTree(QTreeWidget):
", ".join(refs[nwKeyWords.MENTION_KEY]),
novIdx.synopsis,
*story,
*notes,
])
return data
+1 -1
View File
@@ -630,7 +630,7 @@ class _DetailsWidget(QWidget):
self.listView.addTopLevelItem(item)
for key in [
"text.includeSynopsis", "text.includeComments", "text.includeStory",
"text.includeKeywords", "text.includeBodyText",
"text.includeNotes", "text.includeKeywords", "text.includeBodyText",
]:
sub = QTreeWidgetItem()
sub.setText(0, build.getLabel(key))
+4
View File
@@ -974,12 +974,14 @@ class _FormattingTab(NScrollableForm):
self.incSynopsis = NSwitch(self, height=iPx)
self.incComments = NSwitch(self, height=iPx)
self.incStory = NSwitch(self, height=iPx)
self.incNotes = NSwitch(self, height=iPx)
self.incKeywords = NSwitch(self, height=iPx)
self.addRow(self._build.getLabel("text.includeBodyText"), self.incBodyText)
self.addRow(self._build.getLabel("text.includeSynopsis"), self.incSynopsis)
self.addRow(self._build.getLabel("text.includeComments"), self.incComments)
self.addRow(self._build.getLabel("text.includeStory"), self.incStory)
self.addRow(self._build.getLabel("text.includeNotes"), self.incNotes)
self.addRow(self._build.getLabel("text.includeKeywords"), self.incKeywords)
# Ignored Keywords
@@ -1288,6 +1290,7 @@ class _FormattingTab(NScrollableForm):
self.incSynopsis.setChecked(self._build.getBool("text.includeSynopsis"))
self.incComments.setChecked(self._build.getBool("text.includeComments"))
self.incStory.setChecked(self._build.getBool("text.includeStory"))
self.incNotes.setChecked(self._build.getBool("text.includeNotes"))
self.incKeywords.setChecked(self._build.getBool("text.includeKeywords"))
self.ignoredKeywords.setText(self._build.getStr("text.ignoredKeywords"))
self.addNoteHead.setChecked(self._build.getBool("text.addNoteHeadings"))
@@ -1387,6 +1390,7 @@ class _FormattingTab(NScrollableForm):
self._build.setValue("text.includeSynopsis", self.incSynopsis.isChecked())
self._build.setValue("text.includeComments", self.incComments.isChecked())
self._build.setValue("text.includeStory", self.incStory.isChecked())
self._build.setValue("text.includeNotes", self.incNotes.isChecked())
self._build.setValue("text.includeKeywords", self.incKeywords.isChecked())
self._build.setValue("text.ignoredKeywords", self.ignoredKeywords.text())
self._build.setValue("text.addNoteHeadings", self.addNoteHead.isChecked())
+3 -2
View File
@@ -1,8 +1,8 @@
%%~name: Making a Scene
%%~path: 6a2d6d5f4f401/636b6aa9b697b
%%~kind: NOVEL/DOCUMENT
%%~hash: c057a5e9309b0e764c367b0fe9ab0607e3308622
%%~date: Unknown/2025-04-08 20:10:33
%%~hash: 1f3d98a6a27b4f9a7f2239fcd78f9e2263cd3b97
%%~date: Unknown/2025-05-18 22:36:59
### Making a Scene
@pov: Jane
@@ -11,6 +11,7 @@
@mention: Space
%Story.Resolution: You can describe the scene structure with story comments.
%Note.Consistency: You can also make notes about things like consistency of the story.
A scene is defined by a level three heading, like the one at the top of this page. The scene will be assigned to the chapter preceding it in the project tree. The scene document can be sorted after the chapter document, or as a child of the chapter. Both result in the same output in the end, so it is a matter of preference.
+3 -3
View File
@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.7b1" hexVersion="0x020700b1" fileVersion="1.5" fileRevision="5" timeStamp="2025-04-29 22:07:59">
<project id="e2be99af-f9bf-4403-857a-c3d1ac25abea" saveCount="2179" autoCount="285" editTime="96588">
<novelWriterXML appVersion="2.7b1" hexVersion="0x020700b1" fileVersion="1.5" fileRevision="5" timeStamp="2025-05-18 22:37:05">
<project id="e2be99af-f9bf-4403-857a-c3d1ac25abea" saveCount="2189" autoCount="286" editTime="96864">
<name>Sample Project</name>
<author>Jane Smith</author>
</project>
@@ -58,7 +58,7 @@
<name status="sf24ce6" import="ia857f0" active="yes">Chapter One</name>
</item>
<item handle="636b6aa9b697b" parent="6a2d6d5f4f401" root="7031beac91f75" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H3" charCount="2999" wordCount="530" paraCount="16" cursorPos="159" />
<meta expanded="no" heading="H3" charCount="2999" wordCount="530" paraCount="16" cursorPos="718" />
<name status="s90e6c9" import="ia857f0" active="yes">Making a Scene</name>
</item>
<item handle="bc0cbd2a407f3" parent="6a2d6d5f4f401" root="7031beac91f75" order="1" type="FILE" class="NOVEL" layout="DOCUMENT">
+9
View File
@@ -224,6 +224,14 @@ def testCoreIndexData_IndexHeading():
"story.crisis": "It exploded!",
}
# Set Note Comment
head.setComment(nwComment.NOTE.name, "consitency", "Only explode once")
assert head.comments == {
"summary": "In the beginning ...",
"story.crisis": "It exploded!",
"note.consitency": "Only explode once",
}
# Set Tag
head.setTag("Stuff")
assert head.tag == "stuff" # Case insensitive
@@ -240,6 +248,7 @@ def testCoreIndexData_IndexHeading():
"refs": {"stuff": "@object"},
"summary": "In the beginning ...",
"story.crisis": "It exploded!",
"note.consitency": "Only explode once",
}
# Unpack KeyError
+59 -1
View File
@@ -1877,10 +1877,68 @@ def testGuiEditor_Completer(qtbot, nwGUI, projPath, mockRnd):
qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY)
qtbot.keyClick(completer, Qt.Key.Key_Return, delay=KEY_DELAY)
qtbot.keyClick(completer, Qt.Key.Key_Escape, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
assert docEditor.getText() == (
"### Scene One\n\n"
"@char: Jane\n"
"@focus: John"
"@focus: John\n"
)
# Send keypresses to the completer object for a comment
qtbot.keyClick(docEditor, "%", delay=KEY_DELAY)
assert len(completer.actions()) == 4
qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY)
qtbot.keyClick(completer, Qt.Key.Key_Return, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
assert docEditor.getText() == (
"### Scene One\n\n"
"@char: Jane\n"
"@focus: John\n"
"%Synopsis: \n"
)
# Auto-complete story comment
SHARED.project.index._itemIndex._cache.story.add("Resolution")
qtbot.keyClick(docEditor, "%", delay=KEY_DELAY)
assert len(completer.actions()) == 4
qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY)
qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY)
qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY)
qtbot.keyClick(completer, Qt.Key.Key_Return, delay=KEY_DELAY)
qtbot.keyClick(completer, ".", delay=KEY_DELAY)
assert len(completer.actions()) == 1
qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY)
qtbot.keyClick(completer, Qt.Key.Key_Return, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
assert docEditor.getText() == (
"### Scene One\n\n"
"@char: Jane\n"
"@focus: John\n"
"%Synopsis: \n"
"%Story.Resolution: \n"
)
# Auto-complete note comment
SHARED.project.index._itemIndex._cache.note.add("Consistency")
qtbot.keyClick(docEditor, "%", delay=KEY_DELAY)
assert len(completer.actions()) == 4
qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY)
qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY)
qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY)
qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY)
qtbot.keyClick(completer, Qt.Key.Key_Return, delay=KEY_DELAY)
qtbot.keyClick(completer, ".", delay=KEY_DELAY)
assert len(completer.actions()) == 1
qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY)
qtbot.keyClick(completer, Qt.Key.Key_Return, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
assert docEditor.getText() == (
"### Scene One\n\n"
"@char: Jane\n"
"@focus: John\n"
"%Synopsis: \n"
"%Story.Resolution: \n"
"%Note.Consistency: \n"
)
# qtbot.stop()
@@ -510,6 +510,8 @@ def testToolBuildSettings_FormatTextContent(qtbot, nwGUI):
build.setValue("text.includeBodyText", False)
build.setValue("text.includeSynopsis", False)
build.setValue("text.includeComments", False)
build.setValue("text.includeStory", False)
build.setValue("text.includeNotes", False)
build.setValue("text.includeKeywords", False)
build.setValue("text.ignoredKeywords", "")
@@ -530,6 +532,8 @@ def testToolBuildSettings_FormatTextContent(qtbot, nwGUI):
assert fmtTab.incBodyText.isChecked() is False
assert fmtTab.incSynopsis.isChecked() is False
assert fmtTab.incComments.isChecked() is False
assert fmtTab.incStory.isChecked() is False
assert fmtTab.incNotes.isChecked() is False
assert fmtTab.incKeywords.isChecked() is False
assert fmtTab.ignoredKeywords.text() == ""
@@ -539,6 +543,8 @@ def testToolBuildSettings_FormatTextContent(qtbot, nwGUI):
fmtTab.incBodyText.setChecked(True)
fmtTab.incSynopsis.setChecked(True)
fmtTab.incComments.setChecked(True)
fmtTab.incStory.setChecked(True)
fmtTab.incNotes.setChecked(True)
fmtTab.incKeywords.setChecked(True)
fmtTab.addNoteHead.setChecked(True)
@@ -554,6 +560,8 @@ def testToolBuildSettings_FormatTextContent(qtbot, nwGUI):
assert build.getBool("text.includeBodyText") is True
assert build.getBool("text.includeSynopsis") is True
assert build.getBool("text.includeComments") is True
assert build.getBool("text.includeStory") is True
assert build.getBool("text.includeNotes") is True
assert build.getBool("text.includeKeywords") is True
assert build.getStr("text.ignoredKeywords") in ("@custom, @object", "@object, @custom")