Skip to content

Commit 21ce030

Browse files
authored
Improve auto-replace quotes with markup (#2489)
2 parents e9407e0 + c203dcb commit 21ce030

2 files changed

Lines changed: 189 additions & 32 deletions

File tree

novelwriter/gui/doceditor.py

Lines changed: 47 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2293,16 +2293,16 @@ def process(self, text: str, cursor: QTextCursor) -> bool:
22932293
"""Auto-replace text elements based on main configuration.
22942294
Returns True if anything was changed.
22952295
"""
2296-
pos = cursor.positionInBlock()
2297-
apos = cursor.position()
2296+
aPos = cursor.position()
2297+
bPos = cursor.positionInBlock()
22982298
block = cursor.block()
22992299
length = block.length() - 1
2300-
if length < 1 or pos-1 > length:
2300+
if length < 1 or bPos-1 > length:
23012301
return False
23022302

2303-
cursor.movePosition(QtMoveLeft, QtKeepAnchor, min(4, pos))
2303+
cursor.movePosition(QtMoveLeft, QtKeepAnchor, min(4, bPos))
23042304
last = cursor.selectedText()
2305-
delete, insert = self._determine(last, pos)
2305+
delete, insert = self._determine(last, bPos)
23062306

23072307
check = insert
23082308
if self._doPadBefore and check in self._padBefore:
@@ -2320,7 +2320,7 @@ def process(self, text: str, cursor: QTextCursor) -> bool:
23202320
insert = insert + self._padChar
23212321

23222322
if delete > 0:
2323-
cursor.setPosition(apos)
2323+
cursor.setPosition(aPos)
23242324
cursor.movePosition(QtMoveLeft, QtKeepAnchor, delete)
23252325
cursor.insertText(insert)
23262326
return True
@@ -2334,34 +2334,50 @@ def _determine(self, text: str, pos: int) -> tuple[int, str]:
23342334
t3 = text[-3:]
23352335
t4 = text[-4:]
23362336

2337-
leading = t2[:1].isspace()
2338-
if self._replaceDQuote:
2339-
if leading and t2.endswith('"'):
2337+
if self._replaceDQuote and t1 == '"':
2338+
# Process Double Quote
2339+
if pos == 1:
23402340
return 1, self._quoteDO
2341-
elif t1 == '"':
2342-
if pos == 1:
2343-
return 1, self._quoteDO
2344-
elif pos == 2 and t2 == '>"':
2345-
return 1, self._quoteDO
2346-
elif pos == 3 and t3 == '>>"':
2347-
return 1, self._quoteDO
2348-
else:
2349-
return 1, self._quoteDC
2341+
elif t2[:1].isspace() and t2.endswith('"'):
2342+
return 1, self._quoteDO
2343+
elif pos == 2 and t2 == '>"':
2344+
return 1, self._quoteDO
2345+
elif pos == 3 and t3 == '>>"':
2346+
return 1, self._quoteDO
2347+
elif pos == 2 and t2 == '_"':
2348+
return 1, self._quoteDO
2349+
elif t3[:1].isspace() and t3.endswith('_"'):
2350+
return 1, self._quoteDO
2351+
elif pos == 3 and t3 in ('**"', '=="', '~~"'):
2352+
return 1, self._quoteDO
2353+
elif t4[:1].isspace() and t4.endswith(('**"', '=="', '~~"')):
2354+
return 1, self._quoteDO
2355+
else:
2356+
return 1, self._quoteDC
23502357

2351-
if self._replaceSQuote:
2352-
if leading and t2.endswith("'"):
2358+
if self._replaceSQuote and t1 == "'":
2359+
# Process Single Quote
2360+
if pos == 1:
23532361
return 1, self._quoteSO
2354-
elif t1 == "'":
2355-
if pos == 1:
2356-
return 1, self._quoteSO
2357-
elif pos == 2 and t2 == ">'":
2358-
return 1, self._quoteSO
2359-
elif pos == 3 and t3 == ">>'":
2360-
return 1, self._quoteSO
2361-
else:
2362-
return 1, self._quoteSC
2362+
elif t2[:1].isspace() and t2.endswith("'"):
2363+
return 1, self._quoteSO
2364+
elif pos == 2 and t2 == ">'":
2365+
return 1, self._quoteSO
2366+
elif pos == 3 and t3 == ">>'":
2367+
return 1, self._quoteSO
2368+
elif pos == 2 and t2 == "_'":
2369+
return 1, self._quoteSO
2370+
elif t3[:1].isspace() and t3.endswith("_'"):
2371+
return 1, self._quoteSO
2372+
elif pos == 3 and t3 in ("**'", "=='", "~~'"):
2373+
return 1, self._quoteSO
2374+
elif t4[:1].isspace() and t4.endswith(("**'", "=='", "~~'")):
2375+
return 1, self._quoteSO
2376+
else:
2377+
return 1, self._quoteSC
23632378

2364-
if self._replaceDash:
2379+
if self._replaceDash and t1 == "-":
2380+
# Process Dashes
23652381
if t4 == "----":
23662382
return 4, "\u2015" # Horizontal bar
23672383
elif t3 == "---":
@@ -2374,6 +2390,7 @@ def _determine(self, text: str, pos: int) -> tuple[int, str]:
23742390
return 2, "\u2015" # Horizontal bar
23752391

23762392
if self._replaceDots and t3 == "...":
2393+
# Process Dots
23772394
return 3, "\u2026" # Ellipsis
23782395

23792396
if t1 == "\u2028": # Line separator

tests/test_gui/test_gui_doceditor.py

Lines changed: 142 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
from PyQt6.QtCore import QEvent, QMimeData, QPointF, Qt, QThreadPool, QUrl
2828
from PyQt6.QtGui import (
2929
QAction, QClipboard, QDesktopServices, QDragEnterEvent, QDragMoveEvent,
30-
QDropEvent, QFont, QMouseEvent, QTextBlock, QTextCursor, QTextOption
30+
QDropEvent, QFont, QMouseEvent, QTextBlock, QTextCursor, QTextDocument,
31+
QTextOption
3132
)
3233
from PyQt6.QtWidgets import QApplication, QMenu, QPlainTextEdit
3334

@@ -36,7 +37,7 @@
3637
from novelwriter.constants import nwKeyWords, nwUnicode
3738
from novelwriter.dialogs.editlabel import GuiEditLabel
3839
from novelwriter.enum import nwDocAction, nwDocInsert, nwItemClass, nwItemLayout
39-
from novelwriter.gui.doceditor import GuiDocEditor, _TagAction
40+
from novelwriter.gui.doceditor import GuiDocEditor, TextAutoReplace, _TagAction
4041
from novelwriter.gui.dochighlight import TextBlockData
4142
from novelwriter.text.counting import standardCounter
4243
from novelwriter.types import (
@@ -2342,3 +2343,142 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, prjLipsum):
23422343
assert docEditor.textCursor().selectedText() == ""
23432344

23442345
# qtbot.stop()
2346+
2347+
2348+
@pytest.mark.gui
2349+
def testGuiEditor_TextAutoReplaceSymbols():
2350+
"""Test the editor auto-replace functionality."""
2351+
CONFIG.fmtSQuoteOpen = nwUnicode.U_LSQUO
2352+
CONFIG.fmtSQuoteClose = nwUnicode.U_RSQUO
2353+
CONFIG.fmtDQuoteOpen = nwUnicode.U_LDQUO
2354+
CONFIG.fmtDQuoteClose = nwUnicode.U_RDQUO
2355+
2356+
CONFIG.doReplaceSQuote = True
2357+
CONFIG.doReplaceDQuote = True
2358+
CONFIG.doReplaceDash = True
2359+
CONFIG.doReplaceDots = True
2360+
2361+
ar = TextAutoReplace()
2362+
2363+
def prep(text: str) -> tuple[str, int]:
2364+
return text, len(text)
2365+
2366+
# Double Quote Open
2367+
assert ar._determine(*prep('"')) == (1, nwUnicode.U_LDQUO)
2368+
assert ar._determine(*prep('Stuff "')) == (1, nwUnicode.U_LDQUO)
2369+
assert ar._determine(*prep('>"')) == (1, nwUnicode.U_LDQUO)
2370+
assert ar._determine(*prep('>>"')) == (1, nwUnicode.U_LDQUO)
2371+
assert ar._determine(*prep('_"')) == (1, nwUnicode.U_LDQUO)
2372+
assert ar._determine(*prep(' _"')) == (1, nwUnicode.U_LDQUO)
2373+
assert ar._determine(*prep('\u00a0_"')) == (1, nwUnicode.U_LDQUO)
2374+
assert ar._determine(*prep('**"')) == (1, nwUnicode.U_LDQUO)
2375+
assert ar._determine(*prep(' **"')) == (1, nwUnicode.U_LDQUO)
2376+
assert ar._determine(*prep('\u00a0**"')) == (1, nwUnicode.U_LDQUO)
2377+
assert ar._determine(*prep('=="')) == (1, nwUnicode.U_LDQUO)
2378+
assert ar._determine(*prep(' =="')) == (1, nwUnicode.U_LDQUO)
2379+
assert ar._determine(*prep('\u00a0=="')) == (1, nwUnicode.U_LDQUO)
2380+
assert ar._determine(*prep('~~"')) == (1, nwUnicode.U_LDQUO)
2381+
assert ar._determine(*prep(' ~~"')) == (1, nwUnicode.U_LDQUO)
2382+
assert ar._determine(*prep('\u00a0~~"')) == (1, nwUnicode.U_LDQUO)
2383+
2384+
# Double Quote Close
2385+
assert ar._determine(*prep('Stuff"')) == (1, nwUnicode.U_RDQUO)
2386+
2387+
# Single Quote Open
2388+
assert ar._determine(*prep("'")) == (1, nwUnicode.U_LSQUO)
2389+
assert ar._determine(*prep("Stuff '")) == (1, nwUnicode.U_LSQUO)
2390+
assert ar._determine(*prep(">'")) == (1, nwUnicode.U_LSQUO)
2391+
assert ar._determine(*prep(">>'")) == (1, nwUnicode.U_LSQUO)
2392+
assert ar._determine(*prep("_'")) == (1, nwUnicode.U_LSQUO)
2393+
assert ar._determine(*prep(" _'")) == (1, nwUnicode.U_LSQUO)
2394+
assert ar._determine(*prep("\u00a0_'")) == (1, nwUnicode.U_LSQUO)
2395+
assert ar._determine(*prep("**'")) == (1, nwUnicode.U_LSQUO)
2396+
assert ar._determine(*prep(" **'")) == (1, nwUnicode.U_LSQUO)
2397+
assert ar._determine(*prep("\u00a0**'")) == (1, nwUnicode.U_LSQUO)
2398+
assert ar._determine(*prep("=='")) == (1, nwUnicode.U_LSQUO)
2399+
assert ar._determine(*prep(" =='")) == (1, nwUnicode.U_LSQUO)
2400+
assert ar._determine(*prep("\u00a0=='")) == (1, nwUnicode.U_LSQUO)
2401+
assert ar._determine(*prep("~~'")) == (1, nwUnicode.U_LSQUO)
2402+
assert ar._determine(*prep(" ~~'")) == (1, nwUnicode.U_LSQUO)
2403+
assert ar._determine(*prep("\u00a0~~'")) == (1, nwUnicode.U_LSQUO)
2404+
2405+
# Single Quote Close
2406+
assert ar._determine(*prep("Stuff'")) == (1, nwUnicode.U_RSQUO)
2407+
2408+
# Dashes
2409+
assert ar._determine(*prep("-")) == (0, "-")
2410+
assert ar._determine(*prep("--")) == (2, nwUnicode.U_ENDASH)
2411+
assert ar._determine(*prep("---")) == (3, nwUnicode.U_EMDASH)
2412+
assert ar._determine(*prep("----")) == (4, nwUnicode.U_HBAR)
2413+
assert ar._determine(*prep("\u2013-")) == (2, nwUnicode.U_EMDASH)
2414+
assert ar._determine(*prep("\u2014-")) == (2, nwUnicode.U_HBAR)
2415+
2416+
# Ellipsis
2417+
assert ar._determine(*prep(".")) == (0, ".")
2418+
assert ar._determine(*prep("..")) == (0, ".")
2419+
assert ar._determine(*prep("...")) == (3, nwUnicode.U_HELLIP)
2420+
2421+
# Block Typed Line Separator (#1150)
2422+
assert ar._determine(*prep("Text\u2028")) == (1, nwUnicode.U_PSEP)
2423+
2424+
2425+
@pytest.mark.gui
2426+
def testGuiEditor_TextAutoReplaceProcess():
2427+
"""Test the editor auto-replace functionality."""
2428+
CONFIG.fmtDQuoteOpen = nwUnicode.U_LAQUO
2429+
CONFIG.fmtDQuoteClose = nwUnicode.U_RAQUO
2430+
2431+
CONFIG.doReplaceDQuote = True
2432+
CONFIG.doReplaceDots = True
2433+
2434+
ar = TextAutoReplace()
2435+
doc = QTextDocument()
2436+
2437+
def prep(text: str) -> tuple[str, QTextCursor]:
2438+
doc.setPlainText(text)
2439+
cursor = QTextCursor(doc)
2440+
cursor.setPosition(len(text))
2441+
return text, cursor
2442+
2443+
# Nothing to Process
2444+
assert ar.process(*prep("")) is False
2445+
2446+
# Standard Auto-Replace
2447+
assert ar.process(*prep("Text ...")) is True
2448+
assert doc.toRawText() == "Text \u2026"
2449+
2450+
# Pad Before, Normal
2451+
CONFIG.fmtPadBefore = ":\u00bb"
2452+
CONFIG.fmtPadThin = False
2453+
ar.initSettings()
2454+
assert ar.process(*prep("Text:")) is True
2455+
assert doc.toRawText() == "Text\u00a0:"
2456+
assert ar.process(*prep("Text :")) is True # See #1061
2457+
assert doc.toRawText() == "Text\u00a0:"
2458+
assert ar.process(*prep('Text"')) is True
2459+
assert doc.toRawText() == "Text\u00a0»"
2460+
assert ar.process(*prep("@Synopsis:")) is False
2461+
assert doc.toRawText() == "@Synopsis:"
2462+
2463+
# Pad Before, Thin
2464+
CONFIG.fmtPadBefore = ":\u00bb"
2465+
CONFIG.fmtPadThin = True
2466+
ar.initSettings()
2467+
assert ar.process(*prep("Text:")) is True
2468+
assert doc.toRawText() == "Text\u202f:"
2469+
assert ar.process(*prep("Text :")) is True # See #1061
2470+
assert doc.toRawText() == "Text\u202f:"
2471+
2472+
# Pad After, Normal
2473+
CONFIG.fmtPadAfter = "\u00ab"
2474+
CONFIG.fmtPadThin = False
2475+
ar.initSettings()
2476+
assert ar.process(*prep('Text "')) is True
2477+
assert doc.toRawText() == "Text «\u00a0"
2478+
2479+
# Pad After, Thin
2480+
CONFIG.fmtPadAfter = "\u00ab"
2481+
CONFIG.fmtPadThin = True
2482+
ar.initSettings()
2483+
assert ar.process(*prep('Text "')) is True
2484+
assert doc.toRawText() == "Text «\u202f"

0 commit comments

Comments
 (0)