From 8850161641a627350956b7fc93f0cff921b92c90 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 27 Oct 2024 01:55:19 +0200 Subject: [PATCH 1/5] Add new logic for dialogue narrator break --- novelwriter/gui/dochighlight.py | 34 ++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py index ceb4abb43..1931640e0 100644 --- a/novelwriter/gui/dochighlight.py +++ b/novelwriter/gui/dochighlight.py @@ -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: @@ -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") @@ -132,6 +136,9 @@ def initHighlighter(self) -> None: self._txtRules.clear() self._cmnRules.clear() + self._dialogLine = CONFIG.dialogLine + self._narratorBreak = CONFIG.narratorBreak + # Multiple or Trailing Spaces if CONFIG.showMultiSpaces: rxRule = re.compile(r"[ ]{2,}|[ ]*$", re.UNICODE) @@ -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 = { @@ -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:]): From a1a58f51b1ccb096007709af1f9f48630a994237 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 27 Oct 2024 02:15:06 +0200 Subject: [PATCH 2/5] Add new logic for dialogue narrator break to tokenizer --- novelwriter/formats/tokenizer.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/novelwriter/formats/tokenizer.py b/novelwriter/formats/tokenizer.py index 615b32379..0c6eabdd3 100644 --- a/novelwriter/formats/tokenizer.py +++ b/novelwriter/formats/tokenizer.py @@ -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 @@ -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 + self._narratorBreak = CONFIG.narratorBreak return def setTitleMargins(self, upper: float, lower: float) -> None: @@ -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 = [] From bf478a67065ad1a55ac51b8830beaf9fa2c32c9a Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 27 Oct 2024 02:22:39 +0200 Subject: [PATCH 3/5] Update tests --- novelwriter/text/patterns.py | 12 ----------- tests/test_formats/test_fmt_tokenizer.py | 21 ++++++++++++++++--- tests/test_text/test_text_patterns.py | 26 ------------------------ 3 files changed, 18 insertions(+), 41 deletions(-) diff --git a/novelwriter/text/patterns.py b/novelwriter/text/patterns.py index 539c0cf46..64d9cc33e 100644 --- a/novelwriter/text/patterns.py +++ b/novelwriter/text/patterns.py @@ -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.""" diff --git a/tests/test_formats/test_fmt_tokenizer.py b/tests/test_formats/test_fmt_tokenizer.py index ab75254f1..8d01445e0 100644 --- a/tests/test_formats/test_fmt_tokenizer.py +++ b/tests/test_formats/test_fmt_tokenizer.py @@ -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) @@ -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 == [( @@ -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 diff --git a/tests/test_text/test_text_patterns.py b/tests/test_text/test_text_patterns.py index 455e14913..c26fc55d7 100644 --- a/tests/test_text/test_text_patterns.py +++ b/tests/test_text/test_text_patterns.py @@ -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 From b25ba9de27bd0037ed80efaec48c8d2585e9ff75 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 27 Oct 2024 13:05:24 +0100 Subject: [PATCH 4/5] Add test coverage of the highlighter --- .../guiEditor_Main_Final_000000000000f.nwd | 8 +++++-- .../guiEditor_Main_Final_nwProject.nwx | 8 +++---- tests/test_gui/test_gui_guimain.py | 23 ++++++++++++++++++- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/tests/reference/guiEditor_Main_Final_000000000000f.nwd b/tests/reference/guiEditor_Main_Final_000000000000f.nwd index f5402f405..b97e07c1f 100644 --- a/tests/reference/guiEditor_Main_Final_000000000000f.nwd +++ b/tests/reference/guiEditor_Main_Final_000000000000f.nwd @@ -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 @@ -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. diff --git a/tests/reference/guiEditor_Main_Final_nwProject.nwx b/tests/reference/guiEditor_Main_Final_nwProject.nwx index 4c20bd70f..55133ebe2 100644 --- a/tests/reference/guiEditor_Main_Final_nwProject.nwx +++ b/tests/reference/guiEditor_Main_Final_nwProject.nwx @@ -1,6 +1,6 @@ - - + + New Project Jane Doe @@ -28,7 +28,7 @@ Main - + Novel @@ -46,7 +46,7 @@ New Chapter - + New Scene diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index 33f8187eb..f09d885fc 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -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 = "|>" @@ -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) @@ -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 = ":" From 8db092872410c0a911e10b9c7cb82148d52f6f3c Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 27 Oct 2024 13:11:50 +0100 Subject: [PATCH 5/5] Re-order preferences settings and ensure dialog/narrator symbols are single characters --- novelwriter/dialogs/preferences.py | 48 +++++++++++++++--------------- novelwriter/formats/tokenizer.py | 4 +-- novelwriter/gui/dochighlight.py | 4 +-- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/novelwriter/dialogs/preferences.py b/novelwriter/dialogs/preferences.py index 519f6b73b..8d23728a2 100644 --- a/novelwriter/dialogs/preferences.py +++ b/novelwriter/dialogs/preferences.py @@ -543,6 +543,23 @@ 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( @@ -550,16 +567,6 @@ def buildForm(self) -> None: 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) @@ -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) diff --git a/novelwriter/formats/tokenizer.py b/novelwriter/formats/tokenizer.py index 0c6eabdd3..9ba26a895 100644 --- a/novelwriter/formats/tokenizer.py +++ b/novelwriter/formats/tokenizer.py @@ -339,8 +339,8 @@ def setDialogueHighlight(self, state: bool) -> None: REGEX_PATTERNS.altDialogStyle, (TextFmt.COL_B, "altdialog"), (TextFmt.COL_E, ""), )) - self._dialogLine = CONFIG.dialogLine - self._narratorBreak = CONFIG.narratorBreak + self._dialogLine = CONFIG.dialogLine.strip()[:1] + self._narratorBreak = CONFIG.narratorBreak.strip()[:1] return def setTitleMargins(self, upper: float, lower: float) -> None: diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py index 1931640e0..24c4d3738 100644 --- a/novelwriter/gui/dochighlight.py +++ b/novelwriter/gui/dochighlight.py @@ -136,8 +136,8 @@ def initHighlighter(self) -> None: self._txtRules.clear() self._cmnRules.clear() - self._dialogLine = CONFIG.dialogLine - self._narratorBreak = CONFIG.narratorBreak + self._dialogLine = CONFIG.dialogLine.strip()[:1] + self._narratorBreak = CONFIG.narratorBreak.strip()[:1] # Multiple or Trailing Spaces if CONFIG.showMultiSpaces: