From d4fc440725055da184011daa4ee153c1cc8432dd Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 27 Jul 2025 12:25:09 +0200 Subject: [PATCH 1/3] Tweak the editor auto-replace logic to allow markup for dialogue (#2488) --- novelwriter/gui/doceditor.py | 60 ++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 99a14ec2..884c25e2 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -2335,33 +2335,47 @@ class TextAutoReplace: t4 = text[-4:] leading = t2[:1].isspace() - if self._replaceDQuote: - if leading and t2.endswith('"'): + if self._replaceDQuote and t1 == '"': + if pos == 1: return 1, self._quoteDO - elif t1 == '"': - if pos == 1: - return 1, self._quoteDO - elif pos == 2 and t2 == '>"': - return 1, self._quoteDO - elif pos == 3 and t3 == '>>"': - return 1, self._quoteDO - else: - return 1, self._quoteDC + elif leading and t2.endswith('"'): + return 1, self._quoteDO + elif pos == 2 and t2 == '>"': + return 1, self._quoteDO + elif pos == 3 and t3 == '>>"': + return 1, self._quoteDO + elif pos == 2 and t2 == '_"': + return 1, self._quoteDO + elif t3 == ' _"': + return 1, self._quoteDO + elif pos == 3 and t3 in ('**"', '=="', '~~"'): + return 1, self._quoteDO + elif t4 in (' **"', ' =="', ' ~~"'): + return 1, self._quoteDO + else: + return 1, self._quoteDC - if self._replaceSQuote: - if leading and t2.endswith("'"): + if self._replaceSQuote and t1 == "'": + if pos == 1: return 1, self._quoteSO - elif t1 == "'": - if pos == 1: - return 1, self._quoteSO - elif pos == 2 and t2 == ">'": - return 1, self._quoteSO - elif pos == 3 and t3 == ">>'": - return 1, self._quoteSO - else: - return 1, self._quoteSC + elif leading and t2.endswith("'"): + return 1, self._quoteSO + elif pos == 2 and t2 == ">'": + return 1, self._quoteSO + elif pos == 3 and t3 == ">>'": + return 1, self._quoteSO + elif pos == 2 and t2 == "_'": + return 1, self._quoteDO + elif t3 == " _'": + return 1, self._quoteDO + elif pos == 3 and t3 in ("**'", "=='", "~~'"): + return 1, self._quoteDO + elif t4 in (" **'", " =='", " ~~'"): + return 1, self._quoteDO + else: + return 1, self._quoteSC - if self._replaceDash: + if self._replaceDash and t1 == "-": if t4 == "----": return 4, "\u2015" # Horizontal bar elif t3 == "---": From 435a72c76bc07cdc658368cf86a6456e78734992 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 27 Jul 2025 13:19:58 +0200 Subject: [PATCH 2/3] Add test coverage --- novelwriter/gui/doceditor.py | 20 ++-- tests/test_gui/test_gui_doceditor.py | 136 ++++++++++++++++++++++++++- 2 files changed, 144 insertions(+), 12 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 884c25e2..b31aaed0 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -2293,16 +2293,16 @@ class TextAutoReplace: """Auto-replace text elements based on main configuration. Returns True if anything was changed. """ - pos = cursor.positionInBlock() - apos = cursor.position() + aPos = cursor.position() + bPos = cursor.positionInBlock() block = cursor.block() length = block.length() - 1 - if length < 1 or pos-1 > length: + if length < 1 or bPos-1 > length: return False - cursor.movePosition(QtMoveLeft, QtKeepAnchor, min(4, pos)) + cursor.movePosition(QtMoveLeft, QtKeepAnchor, min(4, bPos)) last = cursor.selectedText() - delete, insert = self._determine(last, pos) + delete, insert = self._determine(last, bPos) check = insert if self._doPadBefore and check in self._padBefore: @@ -2320,7 +2320,7 @@ class TextAutoReplace: insert = insert + self._padChar if delete > 0: - cursor.setPosition(apos) + cursor.setPosition(aPos) cursor.movePosition(QtMoveLeft, QtKeepAnchor, delete) cursor.insertText(insert) return True @@ -2365,13 +2365,13 @@ class TextAutoReplace: elif pos == 3 and t3 == ">>'": return 1, self._quoteSO elif pos == 2 and t2 == "_'": - return 1, self._quoteDO + return 1, self._quoteSO elif t3 == " _'": - return 1, self._quoteDO + return 1, self._quoteSO elif pos == 3 and t3 in ("**'", "=='", "~~'"): - return 1, self._quoteDO + return 1, self._quoteSO elif t4 in (" **'", " =='", " ~~'"): - return 1, self._quoteDO + return 1, self._quoteSO else: return 1, self._quoteSC diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index 6480df93..b01871b0 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -27,7 +27,8 @@ import pytest from PyQt6.QtCore import QEvent, QMimeData, QPointF, Qt, QThreadPool, QUrl from PyQt6.QtGui import ( QAction, QClipboard, QDesktopServices, QDragEnterEvent, QDragMoveEvent, - QDropEvent, QFont, QMouseEvent, QTextBlock, QTextCursor, QTextOption + QDropEvent, QFont, QMouseEvent, QTextBlock, QTextCursor, QTextDocument, + QTextOption ) from PyQt6.QtWidgets import QApplication, QMenu, QPlainTextEdit @@ -36,7 +37,7 @@ from novelwriter.common import decodeMimeHandles from novelwriter.constants import nwKeyWords, nwUnicode from novelwriter.dialogs.editlabel import GuiEditLabel from novelwriter.enum import nwDocAction, nwDocInsert, nwItemClass, nwItemLayout -from novelwriter.gui.doceditor import GuiDocEditor, _TagAction +from novelwriter.gui.doceditor import GuiDocEditor, TextAutoReplace, _TagAction from novelwriter.gui.dochighlight import TextBlockData from novelwriter.text.counting import standardCounter from novelwriter.types import ( @@ -2342,3 +2343,134 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, prjLipsum): assert docEditor.textCursor().selectedText() == "" # qtbot.stop() + + +@pytest.mark.gui +def testGuiEditor_TextAutoReplaceSymbols(): + """Test the editor auto-replace functionality.""" + CONFIG.fmtSQuoteOpen = nwUnicode.U_LSQUO + CONFIG.fmtSQuoteClose = nwUnicode.U_RSQUO + CONFIG.fmtDQuoteOpen = nwUnicode.U_LDQUO + CONFIG.fmtDQuoteClose = nwUnicode.U_RDQUO + + CONFIG.doReplaceSQuote = True + CONFIG.doReplaceDQuote = True + CONFIG.doReplaceDash = True + CONFIG.doReplaceDots = True + + ar = TextAutoReplace() + + def prep(text: str) -> tuple[str, int]: + return text, len(text) + + # Double Quote Open + assert ar._determine(*prep('"')) == (1, nwUnicode.U_LDQUO) + assert ar._determine(*prep('Stuff "')) == (1, nwUnicode.U_LDQUO) + assert ar._determine(*prep('>"')) == (1, nwUnicode.U_LDQUO) + assert ar._determine(*prep('>>"')) == (1, nwUnicode.U_LDQUO) + assert ar._determine(*prep('_"')) == (1, nwUnicode.U_LDQUO) + assert ar._determine(*prep(' _"')) == (1, nwUnicode.U_LDQUO) + assert ar._determine(*prep('**"')) == (1, nwUnicode.U_LDQUO) + assert ar._determine(*prep(' **"')) == (1, nwUnicode.U_LDQUO) + assert ar._determine(*prep('=="')) == (1, nwUnicode.U_LDQUO) + assert ar._determine(*prep(' =="')) == (1, nwUnicode.U_LDQUO) + assert ar._determine(*prep('~~"')) == (1, nwUnicode.U_LDQUO) + assert ar._determine(*prep(' ~~"')) == (1, nwUnicode.U_LDQUO) + + # Double Quote Close + assert ar._determine(*prep('Stuff"')) == (1, nwUnicode.U_RDQUO) + + # Single Quote Open + assert ar._determine(*prep("'")) == (1, nwUnicode.U_LSQUO) + assert ar._determine(*prep("Stuff '")) == (1, nwUnicode.U_LSQUO) + assert ar._determine(*prep(">'")) == (1, nwUnicode.U_LSQUO) + assert ar._determine(*prep(">>'")) == (1, nwUnicode.U_LSQUO) + assert ar._determine(*prep("_'")) == (1, nwUnicode.U_LSQUO) + assert ar._determine(*prep(" _'")) == (1, nwUnicode.U_LSQUO) + assert ar._determine(*prep("**'")) == (1, nwUnicode.U_LSQUO) + assert ar._determine(*prep(" **'")) == (1, nwUnicode.U_LSQUO) + assert ar._determine(*prep("=='")) == (1, nwUnicode.U_LSQUO) + assert ar._determine(*prep(" =='")) == (1, nwUnicode.U_LSQUO) + assert ar._determine(*prep("~~'")) == (1, nwUnicode.U_LSQUO) + assert ar._determine(*prep(" ~~'")) == (1, nwUnicode.U_LSQUO) + + # Single Quote Close + assert ar._determine(*prep("Stuff'")) == (1, nwUnicode.U_RSQUO) + + # Dashes + assert ar._determine(*prep("-")) == (0, "-") + assert ar._determine(*prep("--")) == (2, nwUnicode.U_ENDASH) + assert ar._determine(*prep("---")) == (3, nwUnicode.U_EMDASH) + assert ar._determine(*prep("----")) == (4, nwUnicode.U_HBAR) + assert ar._determine(*prep("\u2013-")) == (2, nwUnicode.U_EMDASH) + assert ar._determine(*prep("\u2014-")) == (2, nwUnicode.U_HBAR) + + # Ellipsis + assert ar._determine(*prep(".")) == (0, ".") + assert ar._determine(*prep("..")) == (0, ".") + assert ar._determine(*prep("...")) == (3, nwUnicode.U_HELLIP) + + # Block Typed Line Separator (#1150) + assert ar._determine(*prep("Text\u2028")) == (1, nwUnicode.U_PSEP) + + +@pytest.mark.gui +def testGuiEditor_TextAutoReplaceProcess(): + """Test the editor auto-replace functionality.""" + CONFIG.fmtDQuoteOpen = nwUnicode.U_LAQUO + CONFIG.fmtDQuoteClose = nwUnicode.U_RAQUO + + CONFIG.doReplaceDQuote = True + CONFIG.doReplaceDots = True + + ar = TextAutoReplace() + doc = QTextDocument() + + def prep(text: str) -> tuple[str, QTextCursor]: + doc.setPlainText(text) + cursor = QTextCursor(doc) + cursor.setPosition(len(text)) + return text, cursor + + # Nothing to Process + assert ar.process(*prep("")) is False + + # Standard Auto-Replace + assert ar.process(*prep("Text ...")) is True + assert doc.toRawText() == "Text \u2026" + + # Pad Before, Normal + CONFIG.fmtPadBefore = ":\u00bb" + CONFIG.fmtPadThin = False + ar.initSettings() + assert ar.process(*prep("Text:")) is True + assert doc.toRawText() == "Text\u00a0:" + assert ar.process(*prep("Text :")) is True # See #1061 + assert doc.toRawText() == "Text\u00a0:" + assert ar.process(*prep('Text"')) is True + assert doc.toRawText() == "Text\u00a0»" + assert ar.process(*prep("@Synopsis:")) is False + assert doc.toRawText() == "@Synopsis:" + + # Pad Before, Thin + CONFIG.fmtPadBefore = ":\u00bb" + CONFIG.fmtPadThin = True + ar.initSettings() + assert ar.process(*prep("Text:")) is True + assert doc.toRawText() == "Text\u202f:" + assert ar.process(*prep("Text :")) is True # See #1061 + assert doc.toRawText() == "Text\u202f:" + + # Pad After, Normal + CONFIG.fmtPadAfter = "\u00ab" + CONFIG.fmtPadThin = False + ar.initSettings() + assert ar.process(*prep('Text "')) is True + assert doc.toRawText() == "Text «\u00a0" + + # Pad After, Thin + CONFIG.fmtPadAfter = "\u00ab" + CONFIG.fmtPadThin = True + ar.initSettings() + assert ar.process(*prep('Text "')) is True + assert doc.toRawText() == "Text «\u202f" From c203dcbbb6bcd1292389575df6a7efc1e5d09525 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 27 Jul 2025 13:36:39 +0200 Subject: [PATCH 3/3] Generalise space character detection in quote auto-replace --- novelwriter/gui/doceditor.py | 17 ++++++++++------- tests/test_gui/test_gui_doceditor.py | 8 ++++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index b31aaed0..a4191963 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -2334,11 +2334,11 @@ class TextAutoReplace: t3 = text[-3:] t4 = text[-4:] - leading = t2[:1].isspace() if self._replaceDQuote and t1 == '"': + # Process Double Quote if pos == 1: return 1, self._quoteDO - elif leading and t2.endswith('"'): + elif t2[:1].isspace() and t2.endswith('"'): return 1, self._quoteDO elif pos == 2 and t2 == '>"': return 1, self._quoteDO @@ -2346,19 +2346,20 @@ class TextAutoReplace: return 1, self._quoteDO elif pos == 2 and t2 == '_"': return 1, self._quoteDO - elif t3 == ' _"': + elif t3[:1].isspace() and t3.endswith('_"'): return 1, self._quoteDO elif pos == 3 and t3 in ('**"', '=="', '~~"'): return 1, self._quoteDO - elif t4 in (' **"', ' =="', ' ~~"'): + elif t4[:1].isspace() and t4.endswith(('**"', '=="', '~~"')): return 1, self._quoteDO else: return 1, self._quoteDC if self._replaceSQuote and t1 == "'": + # Process Single Quote if pos == 1: return 1, self._quoteSO - elif leading and t2.endswith("'"): + elif t2[:1].isspace() and t2.endswith("'"): return 1, self._quoteSO elif pos == 2 and t2 == ">'": return 1, self._quoteSO @@ -2366,16 +2367,17 @@ class TextAutoReplace: return 1, self._quoteSO elif pos == 2 and t2 == "_'": return 1, self._quoteSO - elif t3 == " _'": + elif t3[:1].isspace() and t3.endswith("_'"): return 1, self._quoteSO elif pos == 3 and t3 in ("**'", "=='", "~~'"): return 1, self._quoteSO - elif t4 in (" **'", " =='", " ~~'"): + elif t4[:1].isspace() and t4.endswith(("**'", "=='", "~~'")): return 1, self._quoteSO else: return 1, self._quoteSC if self._replaceDash and t1 == "-": + # Process Dashes if t4 == "----": return 4, "\u2015" # Horizontal bar elif t3 == "---": @@ -2388,6 +2390,7 @@ class TextAutoReplace: return 2, "\u2015" # Horizontal bar if self._replaceDots and t3 == "...": + # Process Dots return 3, "\u2026" # Ellipsis if t1 == "\u2028": # Line separator diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index b01871b0..d0ec924d 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -2370,12 +2370,16 @@ def testGuiEditor_TextAutoReplaceSymbols(): assert ar._determine(*prep('>>"')) == (1, nwUnicode.U_LDQUO) assert ar._determine(*prep('_"')) == (1, nwUnicode.U_LDQUO) assert ar._determine(*prep(' _"')) == (1, nwUnicode.U_LDQUO) + assert ar._determine(*prep('\u00a0_"')) == (1, nwUnicode.U_LDQUO) assert ar._determine(*prep('**"')) == (1, nwUnicode.U_LDQUO) assert ar._determine(*prep(' **"')) == (1, nwUnicode.U_LDQUO) + assert ar._determine(*prep('\u00a0**"')) == (1, nwUnicode.U_LDQUO) assert ar._determine(*prep('=="')) == (1, nwUnicode.U_LDQUO) assert ar._determine(*prep(' =="')) == (1, nwUnicode.U_LDQUO) + assert ar._determine(*prep('\u00a0=="')) == (1, nwUnicode.U_LDQUO) assert ar._determine(*prep('~~"')) == (1, nwUnicode.U_LDQUO) assert ar._determine(*prep(' ~~"')) == (1, nwUnicode.U_LDQUO) + assert ar._determine(*prep('\u00a0~~"')) == (1, nwUnicode.U_LDQUO) # Double Quote Close assert ar._determine(*prep('Stuff"')) == (1, nwUnicode.U_RDQUO) @@ -2387,12 +2391,16 @@ def testGuiEditor_TextAutoReplaceSymbols(): assert ar._determine(*prep(">>'")) == (1, nwUnicode.U_LSQUO) assert ar._determine(*prep("_'")) == (1, nwUnicode.U_LSQUO) assert ar._determine(*prep(" _'")) == (1, nwUnicode.U_LSQUO) + assert ar._determine(*prep("\u00a0_'")) == (1, nwUnicode.U_LSQUO) assert ar._determine(*prep("**'")) == (1, nwUnicode.U_LSQUO) assert ar._determine(*prep(" **'")) == (1, nwUnicode.U_LSQUO) + assert ar._determine(*prep("\u00a0**'")) == (1, nwUnicode.U_LSQUO) assert ar._determine(*prep("=='")) == (1, nwUnicode.U_LSQUO) assert ar._determine(*prep(" =='")) == (1, nwUnicode.U_LSQUO) + assert ar._determine(*prep("\u00a0=='")) == (1, nwUnicode.U_LSQUO) assert ar._determine(*prep("~~'")) == (1, nwUnicode.U_LSQUO) assert ar._determine(*prep(" ~~'")) == (1, nwUnicode.U_LSQUO) + assert ar._determine(*prep("\u00a0~~'")) == (1, nwUnicode.U_LSQUO) # Single Quote Close assert ar._determine(*prep("Stuff'")) == (1, nwUnicode.U_RSQUO)