Skip to content

Commit

Permalink
Rewrite narrator break for dialogue highlighting (#2068)
Browse files Browse the repository at this point in the history
  • Loading branch information
vkbo authored Oct 27, 2024
2 parents f931148 + 8db0928 commit 07c700f
Show file tree
Hide file tree
Showing 9 changed files with 111 additions and 97 deletions.
48 changes: 24 additions & 24 deletions novelwriter/dialogs/preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,23 +543,30 @@ def buildForm(self) -> None:
self.tr("Applies to the selected quote styles.")
)

self.altDialogOpen = QLineEdit(self)
self.altDialogOpen.setMaxLength(4)
self.altDialogOpen.setFixedWidth(boxFixed)
self.altDialogOpen.setAlignment(QtAlignCenter)
self.altDialogOpen.setText(CONFIG.altDialogOpen)

self.altDialogClose = QLineEdit(self)
self.altDialogClose.setMaxLength(4)
self.altDialogClose.setFixedWidth(boxFixed)
self.altDialogClose.setAlignment(QtAlignCenter)
self.altDialogClose.setText(CONFIG.altDialogClose)

self.mainForm.addRow(
self.tr("Alternative dialogue symbols"), [self.altDialogOpen, self.altDialogClose],
self.tr("Custom highlighting of dialogue text.")
)

self.allowOpenDial = NSwitch(self)
self.allowOpenDial.setChecked(CONFIG.allowOpenDial)
self.mainForm.addRow(
self.tr("Allow open-ended dialogue"), self.allowOpenDial,
self.tr("Highlight dialogue line with no closing quote.")
)

self.narratorBreak = QLineEdit(self)
self.narratorBreak.setMaxLength(1)
self.narratorBreak.setFixedWidth(boxFixed)
self.narratorBreak.setAlignment(QtAlignCenter)
self.narratorBreak.setText(CONFIG.narratorBreak)
self.mainForm.addRow(
self.tr("Dialogue narrator break symbol"), self.narratorBreak,
self.tr("Symbol to indicate injected narrator break.")
)

self.dialogLine = QLineEdit(self)
self.dialogLine.setMaxLength(1)
self.dialogLine.setFixedWidth(boxFixed)
Expand All @@ -570,21 +577,14 @@ def buildForm(self) -> None:
self.tr("Lines starting with this symbol are dialogue.")
)

self.altDialogOpen = QLineEdit(self)
self.altDialogOpen.setMaxLength(4)
self.altDialogOpen.setFixedWidth(boxFixed)
self.altDialogOpen.setAlignment(QtAlignCenter)
self.altDialogOpen.setText(CONFIG.altDialogOpen)

self.altDialogClose = QLineEdit(self)
self.altDialogClose.setMaxLength(4)
self.altDialogClose.setFixedWidth(boxFixed)
self.altDialogClose.setAlignment(QtAlignCenter)
self.altDialogClose.setText(CONFIG.altDialogClose)

self.narratorBreak = QLineEdit(self)
self.narratorBreak.setMaxLength(1)
self.narratorBreak.setFixedWidth(boxFixed)
self.narratorBreak.setAlignment(QtAlignCenter)
self.narratorBreak.setText(CONFIG.narratorBreak)
self.mainForm.addRow(
self.tr("Alternative dialogue symbols"), [self.altDialogOpen, self.altDialogClose],
self.tr("Custom highlighting of dialogue text.")
self.tr("Dialogue narrator break symbol"), self.narratorBreak,
self.tr("Symbol to indicate injected narrator break.")
)

self.highlightEmph = NSwitch(self)
Expand Down
28 changes: 18 additions & 10 deletions novelwriter/formats/tokenizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,10 @@ def __init__(self, project: NWProject) -> None:
nwShortcode.FOOTNOTE_B: TextFmt.FNOTE,
}

# Dialogue
self._rxDialogue: list[tuple[re.Pattern, tuple[int, str], tuple[int, str]]] = []
self._dialogLine = ""
self._narratorBreak = ""

return

Expand Down Expand Up @@ -331,21 +334,13 @@ def setDialogueHighlight(self, state: bool) -> None:
REGEX_PATTERNS.dialogStyle,
(TextFmt.COL_B, "dialog"), (TextFmt.COL_E, ""),
))
if CONFIG.dialogLine:
self._rxDialogue.append((
REGEX_PATTERNS.dialogLine,
(TextFmt.COL_B, "dialog"), (TextFmt.COL_E, ""),
))
if CONFIG.narratorBreak:
self._rxDialogue.append((
REGEX_PATTERNS.narratorBreak,
(TextFmt.COL_E, ""), (TextFmt.COL_B, "dialog"),
))
if CONFIG.altDialogOpen and CONFIG.altDialogClose:
self._rxDialogue.append((
REGEX_PATTERNS.altDialogStyle,
(TextFmt.COL_B, "altdialog"), (TextFmt.COL_E, ""),
))
self._dialogLine = CONFIG.dialogLine.strip()[:1]
self._narratorBreak = CONFIG.narratorBreak.strip()[:1]
return

def setTitleMargins(self, upper: float, lower: float) -> None:
Expand Down Expand Up @@ -1149,6 +1144,19 @@ def _extractFormats(
temp.append((res.start(0), 0, fmtB, clsB))
temp.append((res.end(0), 0, fmtE, clsE))

if self._dialogLine and text.startswith(self._dialogLine):
if self._narratorBreak:
pos = 0
for num, bit in enumerate(text[1:].split(self._narratorBreak), 1):
length = len(bit) + 1
if num%2:
temp.append((pos, 0, TextFmt.COL_B, "dialog"))
temp.append((pos + length, 0, TextFmt.COL_E, ""))
pos += length
else:
temp.append((0, 0, TextFmt.COL_B, "dialog"))
temp.append((len(text), 0, TextFmt.COL_E, ""))

# Post-process text and format
result = text
formats = []
Expand Down
34 changes: 19 additions & 15 deletions novelwriter/gui/dochighlight.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ class GuiDocHighlighter(QSyntaxHighlighter):

__slots__ = (
"_tHandle", "_isNovel", "_isInactive", "_spellCheck", "_spellErr",
"_hStyles", "_minRules", "_txtRules", "_cmnRules",
"_hStyles", "_minRules", "_txtRules", "_cmnRules", "_dialogLine",
"_narratorBreak",
)

def __init__(self, document: QTextDocument) -> None:
Expand All @@ -78,6 +79,9 @@ def __init__(self, document: QTextDocument) -> None:
self._txtRules: list[tuple[re.Pattern, dict[int, QTextCharFormat]]] = []
self._cmnRules: list[tuple[re.Pattern, dict[int, QTextCharFormat]]] = []

self._dialogLine = ""
self._narratorBreak = ""

self.initHighlighter()

logger.debug("Ready: GuiDocHighlighter")
Expand Down Expand Up @@ -132,6 +136,9 @@ def initHighlighter(self) -> None:
self._txtRules.clear()
self._cmnRules.clear()

self._dialogLine = CONFIG.dialogLine.strip()[:1]
self._narratorBreak = CONFIG.narratorBreak.strip()[:1]

# Multiple or Trailing Spaces
if CONFIG.showMultiSpaces:
rxRule = re.compile(r"[ ]{2,}|[ ]*$", re.UNICODE)
Expand Down Expand Up @@ -159,20 +166,6 @@ def initHighlighter(self) -> None:
}
self._txtRules.append((rxRule, hlRule))

if CONFIG.dialogLine:
rxRule = REGEX_PATTERNS.dialogLine
hlRule = {
0: self._hStyles["dialog"],
}
self._txtRules.append((rxRule, hlRule))

if CONFIG.narratorBreak:
rxRule = REGEX_PATTERNS.narratorBreak
hlRule = {
0: self._hStyles["text"],
}
self._txtRules.append((rxRule, hlRule))

if CONFIG.altDialogOpen and CONFIG.altDialogClose:
rxRule = REGEX_PATTERNS.altDialogStyle
hlRule = {
Expand Down Expand Up @@ -411,6 +404,17 @@ def highlightBlock(self, text: str) -> None:
self.setCurrentBlockState(BLOCK_TEXT)
hRules = self._txtRules if self._isNovel else self._minRules

if self._dialogLine and text.startswith(self._dialogLine):
if self._narratorBreak:
tPos = 0
for tNum, tBit in enumerate(text[1:].split(self._narratorBreak), 1):
tLen = len(tBit) + 1
if tNum%2:
self.setFormat(tPos, tLen, self._hStyles["dialog"])
tPos += tLen
else:
self.setFormat(0, len(text), self._hStyles["dialog"])

if hRules:
for rX, hRule in hRules:
for res in re.finditer(rX, text[xOff:]):
Expand Down
12 changes: 0 additions & 12 deletions novelwriter/text/patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,18 +96,6 @@ def dialogStyle(self) -> re.Pattern:
rxEnd = "|$" if CONFIG.allowOpenDial else ""
return re.compile(f"\\B[{symO}].*?(?:[{symC}]\\B{rxEnd})", re.UNICODE)

@property
def dialogLine(self) -> re.Pattern:
"""Dialogue line rule based on user settings."""
sym = re.escape(CONFIG.dialogLine)
return re.compile(f"^{sym}.*?$", re.UNICODE)

@property
def narratorBreak(self) -> re.Pattern:
"""Dialogue narrator break rule based on user settings."""
sym = re.escape(CONFIG.narratorBreak)
return re.compile(f"\\B{sym}\\S.*?\\S{sym}\\B", re.UNICODE)

@property
def altDialogStyle(self) -> re.Pattern:
"""Dialogue alternative rule based on user settings."""
Expand Down
8 changes: 6 additions & 2 deletions tests/reference/guiEditor_Main_Final_000000000000f.nwd
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
%%~name: New Scene
%%~path: 000000000000d/000000000000f
%%~kind: NOVEL/DOCUMENT
%%~hash: 2a863dc53e09b0b1b0294ae7ea001853dddd596d
%%~date: 2023-10-17 20:52:56/2023-10-17 20:53:01
%%~hash: 0566024662fb6ed7ed645717addd64dabf058915
%%~date: 2024-10-27 13:03:13/2024-10-27 13:03:18
# Novel

## Chapter
Expand Down Expand Up @@ -34,6 +34,10 @@ This is another paragraph of much longer nonsense text. It is in fact 1 very ver

Some “ double quoted text with spaces padded ”.

– Hi, I am a character speaking.

– Hi, I am also a character speaking, – said another character. – How are you?

@object: NoSpaceAdded

% synopsis: No space before this colon.
Expand Down
8 changes: 4 additions & 4 deletions tests/reference/guiEditor_Main_Final_nwProject.nwx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.4rc1" hexVersion="0x020400c1" fileVersion="1.5" fileRevision="3" timeStamp="2024-04-14 23:46:11">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="3" autoCount="2" editTime="3">
<novelWriterXML appVersion="2.6a3" hexVersion="0x020600a3" fileVersion="1.5" fileRevision="4" timeStamp="2024-10-27 13:01:24">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="3" autoCount="2" editTime="6">
<name>New Project</name>
<author>Jane Doe</author>
</project>
Expand Down Expand Up @@ -28,7 +28,7 @@
<entry key="i000007" count="0" red="50" green="200" blue="0" shape="SQUARE">Main</entry>
</importance>
</settings>
<content items="11" novelWords="142" notesWords="27">
<content items="11" novelWords="161" notesWords="27">
<item handle="0000000000008" parent="None" root="0000000000008" order="0" type="ROOT" class="NOVEL">
<meta expanded="yes" />
<name status="s000000" import="i000004">Novel</name>
Expand All @@ -46,7 +46,7 @@
<name status="s000000" import="i000004" active="yes">New Chapter</name>
</item>
<item handle="000000000000f" parent="000000000000d" root="0000000000008" order="1" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H1" charCount="808" wordCount="135" paraCount="15" cursorPos="1026" />
<meta expanded="no" heading="H1" charCount="918" wordCount="154" paraCount="17" cursorPos="1140" />
<name status="s000000" import="i000004" active="yes">New Scene</name>
</item>
<item handle="0000000000009" parent="None" root="0000000000009" order="1" type="ROOT" class="PLOT">
Expand Down
21 changes: 18 additions & 3 deletions tests/test_formats/test_fmt_tokenizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1251,8 +1251,6 @@ def testFmtToken_Dialogue(mockGUI):
CONFIG.dialogStyle = 3
CONFIG.altDialogOpen = "::"
CONFIG.altDialogClose = "::"
CONFIG.dialogLine = "\u2013"
CONFIG.narratorBreak = "\u2013"

project = NWProject()
tokens = BareTokenizer(project)
Expand Down Expand Up @@ -1305,7 +1303,24 @@ def testFmtToken_Dialogue(mockGUI):
BlockFmt.NONE
)]

# Dialogue line
CONFIG.dialogLine = "\u2013"
tokens.setDialogueHighlight(True)
tokens._text = "\u2013 Dialogue line without narrator break.\n"
tokens.tokenizeText()
assert tokens._blocks == [(
BlockTyp.TEXT, "",
"\u2013 Dialogue line without narrator break.",
[
(0, TextFmt.COL_B, "dialog"),
(39, TextFmt.COL_E, ""),
],
BlockFmt.NONE
)]

# Dialogue line with narrator break
CONFIG.narratorBreak = "\u2013"
tokens.setDialogueHighlight(True)
tokens._text = "\u2013 Dialogue with a narrator break, \u2013he said,\u2013 see?\n"
tokens.tokenizeText()
assert tokens._blocks == [(
Expand All @@ -1314,7 +1329,7 @@ def testFmtToken_Dialogue(mockGUI):
[
(0, TextFmt.COL_B, "dialog"),
(34, TextFmt.COL_E, ""),
(44, TextFmt.COL_B, "dialog"),
(43, TextFmt.COL_B, "dialog"),
(49, TextFmt.COL_E, ""),
],
BlockFmt.NONE
Expand Down
23 changes: 22 additions & 1 deletion tests/test_gui/test_gui_guimain.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,6 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
# Syntax Highlighting
CONFIG.dialogStyle = 3
CONFIG.dialogLine = "–"
CONFIG.narratorBreak = "–"
CONFIG.altDialogOpen = "<|"
CONFIG.altDialogClose = "|>"

Expand Down Expand Up @@ -431,6 +430,9 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)

# Dialogue
# ========

for c in "\"Full line double quoted text.\"":
qtbot.keyClick(docEditor, c, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
Expand All @@ -453,6 +455,25 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
docEditor._typPadBefore = ""
docEditor._typPadAfter = ""

# Dialogue Line
for c in "-- Hi, I am a character speaking.":
qtbot.keyClick(docEditor, c, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)

# Narrator Break
CONFIG.narratorBreak = "–"
docEditor = nwGUI.docEditor
docEditor._qDocument.syntaxHighlighter.initHighlighter()

for c in "-- Hi, I am also a character speaking, -- said another character. -- How are you?":
qtbot.keyClick(docEditor, c, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)

# Special Formatting
# ==================

# Insert spaces before colon, but ignore tags and synopsis
docEditor._typPadBefore = ":"

Expand Down
26 changes: 0 additions & 26 deletions tests/test_text/test_text_patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,35 +327,9 @@ def testTextPatterns_DialogueSpecial():
CONFIG.fmtDQuoteClose = nwUnicode.U_RDQUO

CONFIG.dialogStyle = 3
CONFIG.dialogLine = nwUnicode.U_ENDASH
CONFIG.narratorBreak = nwUnicode.U_ENDASH
CONFIG.altDialogOpen = "::"
CONFIG.altDialogClose = "::"

# Dialogue Line
# =============
regEx = REGEX_PATTERNS.dialogLine

# Check dialogue line in first position
assert allMatches(regEx, "\u2013 one two three") == [
[("\u2013 one two three", 0, 15)]
]

# Check dialogue line in second position
assert allMatches(regEx, " \u2013 one two three") == []

# Narrator Break
# ==============
regEx = REGEX_PATTERNS.narratorBreak

# Narrator break with no padding
assert allMatches(regEx, "one \u2013two\u2013 three") == [
[("\u2013two\u2013", 4, 9)]
]

# Narrator break with padding
assert allMatches(regEx, "one \u2013 two \u2013 three") == []

# Alternative Dialogue
# ====================
regEx = REGEX_PATTERNS.altDialogStyle
Expand Down

0 comments on commit 07c700f

Please sign in to comment.