Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rewrite narrator break for dialogue highlighting #2068

Merged
merged 5 commits into from
Oct 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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