Move editor auto-replace to a separate class

This commit is contained in:
Veronica Berglyd Olsen
2024-12-05 22:40:15 +01:00
parent 6502c424e2
commit 924a2c3381
2 changed files with 157 additions and 170 deletions
+155 -168
View File
@@ -36,7 +36,6 @@ import logging
from enum import Enum from enum import Enum
from time import time from time import time
from typing import NamedTuple
from PyQt5.QtCore import ( from PyQt5.QtCore import (
QObject, QPoint, QRegularExpression, QRunnable, Qt, QTimer, pyqtSignal, QObject, QPoint, QRegularExpression, QRunnable, Qt, QTimer, pyqtSignal,
@@ -85,21 +84,6 @@ class _SelectAction(Enum):
MOVE_AFTER = 3 MOVE_AFTER = 3
class AutoReplaceConfig(NamedTuple):
typPadChar: str
typSQuoteO: str
typSQuoteC: str
typDQuoteO: str
typDQuoteC: str
typRepDQuote: bool
typRepSQuote: bool
typRepDash: bool
typRepDots: bool
typPadBefore: str
typPadAfter: str
class GuiDocEditor(QPlainTextEdit): class GuiDocEditor(QPlainTextEdit):
"""Gui Widget: Main Document Editor""" """Gui Widget: Main Document Editor"""
@@ -144,20 +128,8 @@ class GuiDocEditor(QPlainTextEdit):
self._lastFind = None # Position of the last found search word self._lastFind = None # Position of the last found search word
self._doReplace = False # Switch to temporarily disable auto-replace self._doReplace = False # Switch to temporarily disable auto-replace
# Typography Cache # Auto-Replace
self._typConf = AutoReplaceConfig( self._autoReplace = TextAutoReplace()
typPadChar=" ",
typSQuoteO="'",
typSQuoteC="'",
typDQuoteO='"',
typDQuoteC='"',
typRepSQuote=False,
typRepDQuote=False,
typRepDash=False,
typRepDots=False,
typPadBefore="",
typPadAfter="",
)
# Completer # Completer
self._completer = MetaCompleter(self) self._completer = MetaCompleter(self)
@@ -329,20 +301,8 @@ class GuiDocEditor(QPlainTextEdit):
settings. This function is both called when the editor is settings. This function is both called when the editor is
created, and when the user changes the main editor preferences. created, and when the user changes the main editor preferences.
""" """
# Typography # Auto-Replace
self._typConf = AutoReplaceConfig( self._autoReplace.initSettings()
typPadChar=nwUnicode.U_THNBSP if CONFIG.fmtPadThin else nwUnicode.U_NBSP,
typSQuoteO=CONFIG.fmtSQuoteOpen,
typSQuoteC=CONFIG.fmtSQuoteClose,
typDQuoteO=CONFIG.fmtDQuoteOpen,
typDQuoteC=CONFIG.fmtDQuoteClose,
typRepSQuote=CONFIG.doReplaceSQuote,
typRepDQuote=CONFIG.doReplaceDQuote,
typRepDash=CONFIG.doReplaceDash,
typRepDots=CONFIG.doReplaceDots,
typPadBefore=CONFIG.fmtPadBefore,
typPadAfter=CONFIG.fmtPadAfter,
)
# Reload spell check and dictionaries # Reload spell check and dictionaries
SHARED.updateSpellCheckLanguage() SHARED.updateSpellCheckLanguage()
@@ -757,7 +717,6 @@ class GuiDocEditor(QPlainTextEdit):
logger.debug("Requesting action: %s", action.name) logger.debug("Requesting action: %s", action.name)
tConf = self._typConf
self._allowAutoReplace(False) self._allowAutoReplace(False)
if action == nwDocAction.UNDO: if action == nwDocAction.UNDO:
self.undo() self.undo()
@@ -776,9 +735,9 @@ class GuiDocEditor(QPlainTextEdit):
elif action == nwDocAction.MD_STRIKE: elif action == nwDocAction.MD_STRIKE:
self._toggleFormat(2, "~") self._toggleFormat(2, "~")
elif action == nwDocAction.S_QUOTE: elif action == nwDocAction.S_QUOTE:
self._wrapSelection(tConf.typSQuoteO, tConf.typSQuoteC) self._wrapSelection(CONFIG.fmtSQuoteOpen, CONFIG.fmtSQuoteClose)
elif action == nwDocAction.D_QUOTE: elif action == nwDocAction.D_QUOTE:
self._wrapSelection(tConf.typDQuoteO, tConf.typDQuoteC) self._wrapSelection(CONFIG.fmtDQuoteOpen, CONFIG.fmtDQuoteClose)
elif action == nwDocAction.SEL_ALL: elif action == nwDocAction.SEL_ALL:
self._makeSelection(QTextCursor.SelectionType.Document) self._makeSelection(QTextCursor.SelectionType.Document)
elif action == nwDocAction.SEL_PARA: elif action == nwDocAction.SEL_PARA:
@@ -804,9 +763,9 @@ class GuiDocEditor(QPlainTextEdit):
elif action == nwDocAction.BLOCK_HSC: elif action == nwDocAction.BLOCK_HSC:
self._formatBlock(nwDocAction.BLOCK_HSC) self._formatBlock(nwDocAction.BLOCK_HSC)
elif action == nwDocAction.REPL_SNG: elif action == nwDocAction.REPL_SNG:
self._replaceQuotes("'", tConf.typSQuoteO, tConf.typSQuoteC) self._replaceQuotes("'", CONFIG.fmtSQuoteOpen, CONFIG.fmtSQuoteClose)
elif action == nwDocAction.REPL_DBL: elif action == nwDocAction.REPL_DBL:
self._replaceQuotes("\"", tConf.typDQuoteO, tConf.typDQuoteC) self._replaceQuotes("\"", CONFIG.fmtDQuoteOpen, CONFIG.fmtDQuoteClose)
elif action == nwDocAction.RM_BREAKS: elif action == nwDocAction.RM_BREAKS:
self._removeInParLineBreaks() self._removeInParLineBreaks()
elif action == nwDocAction.ALIGN_L: elif action == nwDocAction.ALIGN_L:
@@ -878,13 +837,13 @@ class GuiDocEditor(QPlainTextEdit):
text = insert text = insert
elif isinstance(insert, nwDocInsert): elif isinstance(insert, nwDocInsert):
if insert == nwDocInsert.QUOTE_LS: if insert == nwDocInsert.QUOTE_LS:
text = self._typConf.typSQuoteO text = CONFIG.fmtSQuoteOpen
elif insert == nwDocInsert.QUOTE_RS: elif insert == nwDocInsert.QUOTE_RS:
text = self._typConf.typSQuoteC text = CONFIG.fmtSQuoteClose
elif insert == nwDocInsert.QUOTE_LD: elif insert == nwDocInsert.QUOTE_LD:
text = self._typConf.typDQuoteO text = CONFIG.fmtDQuoteOpen
elif insert == nwDocInsert.QUOTE_RD: elif insert == nwDocInsert.QUOTE_RD:
text = self._typConf.typDQuoteC text = CONFIG.fmtDQuoteClose
elif insert == nwDocInsert.SYNOPSIS: elif insert == nwDocInsert.SYNOPSIS:
text = "%Synopsis: " text = "%Synopsis: "
block = True block = True
@@ -1139,7 +1098,11 @@ class GuiDocEditor(QPlainTextEdit):
self._completer.setVisible(False) self._completer.setVisible(False)
if self._doReplace and added == 1: if self._doReplace and added == 1:
self._docAutoReplace(text) tStart = time()
cursor = self.textCursor()
if self._autoReplace.process(text, cursor):
self._qDocument.syntaxHighlighter.rehighlightBlock(cursor.block())
logger.debug("Auto-replace processed in %.3f µs", 1.0e6*(time() - tStart))
return return
@@ -2018,120 +1981,6 @@ class GuiDocEditor(QPlainTextEdit):
self.requestProjectItemRenamed.emit(self._docHandle, text) self.requestProjectItemRenamed.emit(self._docHandle, text)
return return
def _docAutoReplace(self, text: str) -> None:
"""Auto-replace text elements based on main configuration."""
cursor = self.textCursor()
tPos = cursor.positionInBlock()
tLen = len(text)
if tLen < 1 or tPos-1 > tLen:
return
t1 = text[tPos-1:tPos]
t2 = text[tPos-2:tPos]
t3 = text[tPos-3:tPos]
t4 = text[tPos-4:tPos]
if not t1:
return
delete = 0
insert = t1
tConf = self._typConf
if tConf.typRepDQuote and t2[:1].isspace() and t2.endswith('"'):
delete = 1
insert = tConf.typDQuoteO
elif tConf.typRepDQuote and t1 == '"':
delete = 1
if tPos == 1:
insert = tConf.typDQuoteO
elif tPos == 2 and t2 == '>"':
insert = tConf.typDQuoteO
elif tPos == 3 and t3 == '>>"':
insert = tConf.typDQuoteO
else:
insert = tConf.typDQuoteC
elif tConf.typRepSQuote and t2[:1].isspace() and t2.endswith("'"):
delete = 1
insert = tConf.typSQuoteO
elif tConf.typRepSQuote and t1 == "'":
delete = 1
if tPos == 1:
insert = tConf.typSQuoteO
elif tPos == 2 and t2 == ">'":
insert = tConf.typSQuoteO
elif tPos == 3 and t3 == ">>'":
insert = tConf.typSQuoteO
else:
insert = tConf.typSQuoteC
elif tConf.typRepDash and t4 == "----":
delete = 4
insert = nwUnicode.U_HBAR
elif tConf.typRepDash and t3 == "---":
delete = 3
insert = nwUnicode.U_EMDASH
elif tConf.typRepDash and t2 == "--":
delete = 2
insert = nwUnicode.U_ENDASH
elif tConf.typRepDash and t2 == nwUnicode.U_ENDASH + "-":
delete = 2
insert = nwUnicode.U_EMDASH
elif tConf.typRepDash and t2 == nwUnicode.U_EMDASH + "-":
delete = 2
insert = nwUnicode.U_HBAR
elif tConf.typRepDots and t3 == "...":
delete = 3
insert = nwUnicode.U_HELLIP
elif t1 == nwUnicode.U_LSEP:
# This resolves issue #1150
delete = 1
insert = nwUnicode.U_PSEP
check = insert
if tConf.typPadBefore and check in tConf.typPadBefore:
if self._allowSpaceBeforeColon(text, check):
delete = max(delete, 1)
chkPos = tPos - delete - 1
if chkPos >= 0 and text[chkPos].isspace():
# Strip existing space before inserting a new (#1061)
delete += 1
insert = tConf.typPadChar + insert
if tConf.typPadAfter and check in tConf.typPadAfter:
if self._allowSpaceBeforeColon(text, check):
delete = max(delete, 1)
insert = insert + tConf.typPadChar
if delete > 0:
cursor.movePosition(QtMoveLeft, QtKeepAnchor, delete)
cursor.insertText(insert)
# Re-highlight, since the auto-replace sometimes interferes with it
self._qDocument.syntaxHighlighter.rehighlightBlock(cursor.block())
return
@staticmethod
def _allowSpaceBeforeColon(text: str, char: str) -> bool:
"""Special checker function only used by the insert space
feature for French, Spanish, etc, so it doesn't insert a
space before colons in meta data lines. See issue #1090.
"""
if char == ":" and len(text) > 1 and text[0] == "@":
return False
return True
def _autoSelect(self) -> QTextCursor: def _autoSelect(self) -> QTextCursor:
"""Return a cursor which may or may not have a selection based """Return a cursor which may or may not have a selection based
on user settings and document action. The selection will be the on user settings and document action. The selection will be the
@@ -2340,6 +2189,144 @@ class BackgroundWordCounterSignals(QObject):
countsReady = pyqtSignal(int, int, int) countsReady = pyqtSignal(int, int, int)
class TextAutoReplace:
__slots__ = (
"_typPadChar", "_typSQuoteO", "_typSQuoteC", "_typDQuoteO", "_typDQuoteC",
"_typRepSQuote", "_typRepDQuote", "_typRepDash", "_typRepDots",
"_typPadBefore", "_typPadAfter",
)
def __init__(self) -> None:
self.initSettings()
return
def initSettings(self) -> None:
"""Initialise the auto-replace settings from config."""
self._typPadChar = nwUnicode.U_THNBSP if CONFIG.fmtPadThin else nwUnicode.U_NBSP
self._typSQuoteO = CONFIG.fmtSQuoteOpen
self._typSQuoteC = CONFIG.fmtSQuoteClose
self._typDQuoteO = CONFIG.fmtDQuoteOpen
self._typDQuoteC = CONFIG.fmtDQuoteClose
self._typRepSQuote = CONFIG.doReplaceSQuote
self._typRepDQuote = CONFIG.doReplaceDQuote
self._typRepDash = CONFIG.doReplaceDash
self._typRepDots = CONFIG.doReplaceDots
self._typPadBefore = CONFIG.fmtPadBefore
self._typPadAfter = CONFIG.fmtPadAfter
return
def process(self, text: str, cursor: QTextCursor) -> bool:
"""Auto-replace text elements based on main configuration."""
tPos = cursor.positionInBlock()
tLen = len(text)
if tLen < 1 or tPos-1 > tLen:
return False
t1 = text[tPos-1:tPos]
t2 = text[tPos-2:tPos]
t3 = text[tPos-3:tPos]
t4 = text[tPos-4:tPos]
if not t1:
return False
delete = 0
insert = t1
if self._typRepDQuote and t2[:1].isspace() and t2.endswith('"'):
delete = 1
insert = self._typDQuoteO
elif self._typRepDQuote and t1 == '"':
delete = 1
if tPos == 1:
insert = self._typDQuoteO
elif tPos == 2 and t2 == '>"':
insert = self._typDQuoteO
elif tPos == 3 and t3 == '>>"':
insert = self._typDQuoteO
else:
insert = self._typDQuoteC
elif self._typRepSQuote and t2[:1].isspace() and t2.endswith("'"):
delete = 1
insert = self._typSQuoteO
elif self._typRepSQuote and t1 == "'":
delete = 1
if tPos == 1:
insert = self._typSQuoteO
elif tPos == 2 and t2 == ">'":
insert = self._typSQuoteO
elif tPos == 3 and t3 == ">>'":
insert = self._typSQuoteO
else:
insert = self._typSQuoteC
elif self._typRepDash and t4 == "----":
delete = 4
insert = nwUnicode.U_HBAR
elif self._typRepDash and t3 == "---":
delete = 3
insert = nwUnicode.U_EMDASH
elif self._typRepDash and t2 == "--":
delete = 2
insert = nwUnicode.U_ENDASH
elif self._typRepDash and t2 == nwUnicode.U_ENDASH + "-":
delete = 2
insert = nwUnicode.U_EMDASH
elif self._typRepDash and t2 == nwUnicode.U_EMDASH + "-":
delete = 2
insert = nwUnicode.U_HBAR
elif self._typRepDots and t3 == "...":
delete = 3
insert = nwUnicode.U_HELLIP
elif t1 == nwUnicode.U_LSEP:
# This resolves issue #1150
delete = 1
insert = nwUnicode.U_PSEP
check = insert
if self._typPadBefore and check in self._typPadBefore:
if self._allowSpaceBeforeColon(text, check):
delete = max(delete, 1)
chkPos = tPos - delete - 1
if chkPos >= 0 and text[chkPos].isspace():
# Strip existing space before inserting a new (#1061)
delete += 1
insert = self._typPadChar + insert
if self._typPadAfter and check in self._typPadAfter:
if self._allowSpaceBeforeColon(text, check):
delete = max(delete, 1)
insert = insert + self._typPadChar
if delete > 0:
cursor.movePosition(QtMoveLeft, QtKeepAnchor, delete)
cursor.insertText(insert)
return True
return False
@staticmethod
def _allowSpaceBeforeColon(text: str, char: str) -> bool:
"""Special checker function only used by the insert space
feature for French, Spanish, etc, so it doesn't insert a
space before colons in meta data lines. See issue #1090.
"""
if char == ":" and len(text) > 1 and text[0] == "@":
return False
return True
class GuiDocToolBar(QWidget): class GuiDocToolBar(QWidget):
"""The Formatting and Options Fold Out Menu """The Formatting and Options Fold Out Menu
+2 -2
View File
@@ -81,7 +81,7 @@ def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd):
assert qDoc.defaultTextOption().alignment() == QtAlignLeft assert qDoc.defaultTextOption().alignment() == QtAlignLeft
assert docEditor.verticalScrollBarPolicy() == QtScrollAsNeeded assert docEditor.verticalScrollBarPolicy() == QtScrollAsNeeded
assert docEditor.horizontalScrollBarPolicy() == QtScrollAsNeeded assert docEditor.horizontalScrollBarPolicy() == QtScrollAsNeeded
assert docEditor._typConf.typPadChar == nwUnicode.U_NBSP assert docEditor._autoReplace._typPadChar == nwUnicode.U_NBSP
assert docEditor.docHeader.itemTitle.text() == ( assert docEditor.docHeader.itemTitle.text() == (
"Novel \u203a New Folder \u203a New Scene" "Novel \u203a New Folder \u203a New Scene"
) )
@@ -106,7 +106,7 @@ def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd):
assert qDoc.defaultTextOption().flags() & QTextOption.ShowLineAndParagraphSeparators assert qDoc.defaultTextOption().flags() & QTextOption.ShowLineAndParagraphSeparators
assert docEditor.verticalScrollBarPolicy() == QtScrollAlwaysOff assert docEditor.verticalScrollBarPolicy() == QtScrollAlwaysOff
assert docEditor.horizontalScrollBarPolicy() == QtScrollAlwaysOff assert docEditor.horizontalScrollBarPolicy() == QtScrollAlwaysOff
assert docEditor._typConf.typPadChar == nwUnicode.U_THNBSP assert docEditor._autoReplace._typPadChar == nwUnicode.U_THNBSP
assert docEditor.docHeader.itemTitle.text() == "New Scene" assert docEditor.docHeader.itemTitle.text() == "New Scene"
# Header # Header