Improve auto-replace quotes with markup (#2489)

This commit is contained in:
Veronica Berglyd Olsen
2025-07-27 13:46:01 +02:00
committed by GitHub
2 changed files with 189 additions and 32 deletions
+47 -30
View File
@@ -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
@@ -2334,34 +2334,50 @@ class TextAutoReplace:
t3 = text[-3:]
t4 = text[-4:]
leading = t2[:1].isspace()
if self._replaceDQuote:
if leading and t2.endswith('"'):
if self._replaceDQuote and t1 == '"':
# Process Double Quote
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 t2[:1].isspace() 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[:1].isspace() and t3.endswith('_"'):
return 1, self._quoteDO
elif pos == 3 and t3 in ('**"', '=="', '~~"'):
return 1, self._quoteDO
elif t4[:1].isspace() and t4.endswith(('**"', '=="', '~~"')):
return 1, self._quoteDO
else:
return 1, self._quoteDC
if self._replaceSQuote:
if leading and t2.endswith("'"):
if self._replaceSQuote and t1 == "'":
# Process Single Quote
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 t2[:1].isspace() 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._quoteSO
elif t3[:1].isspace() and t3.endswith("_'"):
return 1, self._quoteSO
elif pos == 3 and t3 in ("**'", "=='", "~~'"):
return 1, self._quoteSO
elif t4[:1].isspace() and t4.endswith(("**'", "=='", "~~'")):
return 1, self._quoteSO
else:
return 1, self._quoteSC
if self._replaceDash:
if self._replaceDash and t1 == "-":
# Process Dashes
if t4 == "----":
return 4, "\u2015" # Horizontal bar
elif t3 == "---":
@@ -2374,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
+142 -2
View File
@@ -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,142 @@ 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('\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)
# 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("\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)
# 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"