From 924a2c3381f16f3f7770e25f4c08dc4c97c603a8 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 5 Dec 2024 22:40:15 +0100 Subject: [PATCH 1/4] Move editor auto-replace to a separate class --- novelwriter/gui/doceditor.py | 323 +++++++++++++-------------- tests/test_gui/test_gui_doceditor.py | 4 +- 2 files changed, 157 insertions(+), 170 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 1e53c7f7..529d6d61 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -36,7 +36,6 @@ import logging from enum import Enum from time import time -from typing import NamedTuple from PyQt5.QtCore import ( QObject, QPoint, QRegularExpression, QRunnable, Qt, QTimer, pyqtSignal, @@ -85,21 +84,6 @@ class _SelectAction(Enum): 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): """Gui Widget: Main Document Editor""" @@ -144,20 +128,8 @@ class GuiDocEditor(QPlainTextEdit): self._lastFind = None # Position of the last found search word self._doReplace = False # Switch to temporarily disable auto-replace - # Typography Cache - self._typConf = AutoReplaceConfig( - typPadChar=" ", - typSQuoteO="'", - typSQuoteC="'", - typDQuoteO='"', - typDQuoteC='"', - typRepSQuote=False, - typRepDQuote=False, - typRepDash=False, - typRepDots=False, - typPadBefore="", - typPadAfter="", - ) + # Auto-Replace + self._autoReplace = TextAutoReplace() # Completer self._completer = MetaCompleter(self) @@ -329,20 +301,8 @@ class GuiDocEditor(QPlainTextEdit): settings. This function is both called when the editor is created, and when the user changes the main editor preferences. """ - # Typography - self._typConf = AutoReplaceConfig( - 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, - ) + # Auto-Replace + self._autoReplace.initSettings() # Reload spell check and dictionaries SHARED.updateSpellCheckLanguage() @@ -757,7 +717,6 @@ class GuiDocEditor(QPlainTextEdit): logger.debug("Requesting action: %s", action.name) - tConf = self._typConf self._allowAutoReplace(False) if action == nwDocAction.UNDO: self.undo() @@ -776,9 +735,9 @@ class GuiDocEditor(QPlainTextEdit): elif action == nwDocAction.MD_STRIKE: self._toggleFormat(2, "~") elif action == nwDocAction.S_QUOTE: - self._wrapSelection(tConf.typSQuoteO, tConf.typSQuoteC) + self._wrapSelection(CONFIG.fmtSQuoteOpen, CONFIG.fmtSQuoteClose) elif action == nwDocAction.D_QUOTE: - self._wrapSelection(tConf.typDQuoteO, tConf.typDQuoteC) + self._wrapSelection(CONFIG.fmtDQuoteOpen, CONFIG.fmtDQuoteClose) elif action == nwDocAction.SEL_ALL: self._makeSelection(QTextCursor.SelectionType.Document) elif action == nwDocAction.SEL_PARA: @@ -804,9 +763,9 @@ class GuiDocEditor(QPlainTextEdit): elif action == nwDocAction.BLOCK_HSC: self._formatBlock(nwDocAction.BLOCK_HSC) elif action == nwDocAction.REPL_SNG: - self._replaceQuotes("'", tConf.typSQuoteO, tConf.typSQuoteC) + self._replaceQuotes("'", CONFIG.fmtSQuoteOpen, CONFIG.fmtSQuoteClose) elif action == nwDocAction.REPL_DBL: - self._replaceQuotes("\"", tConf.typDQuoteO, tConf.typDQuoteC) + self._replaceQuotes("\"", CONFIG.fmtDQuoteOpen, CONFIG.fmtDQuoteClose) elif action == nwDocAction.RM_BREAKS: self._removeInParLineBreaks() elif action == nwDocAction.ALIGN_L: @@ -878,13 +837,13 @@ class GuiDocEditor(QPlainTextEdit): text = insert elif isinstance(insert, nwDocInsert): if insert == nwDocInsert.QUOTE_LS: - text = self._typConf.typSQuoteO + text = CONFIG.fmtSQuoteOpen elif insert == nwDocInsert.QUOTE_RS: - text = self._typConf.typSQuoteC + text = CONFIG.fmtSQuoteClose elif insert == nwDocInsert.QUOTE_LD: - text = self._typConf.typDQuoteO + text = CONFIG.fmtDQuoteOpen elif insert == nwDocInsert.QUOTE_RD: - text = self._typConf.typDQuoteC + text = CONFIG.fmtDQuoteClose elif insert == nwDocInsert.SYNOPSIS: text = "%Synopsis: " block = True @@ -1139,7 +1098,11 @@ class GuiDocEditor(QPlainTextEdit): self._completer.setVisible(False) 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 @@ -2018,120 +1981,6 @@ class GuiDocEditor(QPlainTextEdit): self.requestProjectItemRenamed.emit(self._docHandle, text) 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: """Return a cursor which may or may not have a selection based on user settings and document action. The selection will be the @@ -2340,6 +2189,144 @@ class BackgroundWordCounterSignals(QObject): 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): """The Formatting and Options Fold Out Menu diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index fd39e5f8..9957b1dc 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -81,7 +81,7 @@ def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd): assert qDoc.defaultTextOption().alignment() == QtAlignLeft assert docEditor.verticalScrollBarPolicy() == QtScrollAsNeeded assert docEditor.horizontalScrollBarPolicy() == QtScrollAsNeeded - assert docEditor._typConf.typPadChar == nwUnicode.U_NBSP + assert docEditor._autoReplace._typPadChar == nwUnicode.U_NBSP assert docEditor.docHeader.itemTitle.text() == ( "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 docEditor.verticalScrollBarPolicy() == 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" # Header From 05385ba73b0793d0dc31e8a7a389966e1bb1364d Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 5 Dec 2024 22:57:43 +0100 Subject: [PATCH 2/4] Improve the text auto-replace class --- novelwriter/gui/doceditor.py | 195 +++++++++++++-------------- tests/test_gui/test_gui_doceditor.py | 4 +- 2 files changed, 92 insertions(+), 107 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 529d6d61..90b295c2 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -1098,11 +1098,9 @@ class GuiDocEditor(QPlainTextEdit): self._completer.setVisible(False) if self._doReplace and added == 1: - 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 @@ -2192,9 +2190,9 @@ class BackgroundWordCounterSignals(QObject): class TextAutoReplace: __slots__ = ( - "_typPadChar", "_typSQuoteO", "_typSQuoteC", "_typDQuoteO", "_typDQuoteC", - "_typRepSQuote", "_typRepDQuote", "_typRepDash", "_typRepDots", - "_typPadBefore", "_typPadAfter", + "_quoteSO", "_quoteSC", "_quoteDO", "_quoteDC", + "_replaceSQuote", "_replaceDQuote", "_replaceDash", "_replaceDots", + "_padChar", "_padBefore", "_padAfter", "_doPadBefore", "_doPadAfter", ) def __init__(self) -> None: @@ -2203,111 +2201,50 @@ class TextAutoReplace: 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 + self._quoteSO = CONFIG.fmtSQuoteOpen + self._quoteSC = CONFIG.fmtSQuoteClose + self._quoteDO = CONFIG.fmtDQuoteOpen + self._quoteDC = CONFIG.fmtDQuoteClose + + self._replaceSQuote = CONFIG.doReplaceSQuote + self._replaceDQuote = CONFIG.doReplaceDQuote + self._replaceDash = CONFIG.doReplaceDash + self._replaceDots = CONFIG.doReplaceDots + + self._padChar = nwUnicode.U_THNBSP if CONFIG.fmtPadThin else nwUnicode.U_NBSP + self._padBefore = CONFIG.fmtPadBefore + self._padAfter = CONFIG.fmtPadAfter + self._doPadBefore = bool(CONFIG.fmtPadBefore) + self._doPadAfter = bool(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: + """Auto-replace text elements based on main configuration. + Returns True if anything was changed. + """ + pos = cursor.positionInBlock() + length = len(text) + if length < 1 or pos-1 > length: 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: + delete, insert = self._determine(text, pos) + if insert == "": 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): + if self._doPadBefore and check in self._padBefore: + if not (check == ":" and length > 1 and text[0] == "@"): delete = max(delete, 1) - chkPos = tPos - delete - 1 + chkPos = pos - delete - 1 if chkPos >= 0 and text[chkPos].isspace(): # Strip existing space before inserting a new (#1061) delete += 1 - insert = self._typPadChar + insert + insert = self._padChar + insert - if self._typPadAfter and check in self._typPadAfter: - if self._allowSpaceBeforeColon(text, check): + if self._doPadAfter and check in self._padAfter: + if not (check == ":" and length > 1 and text[0] == "@"): delete = max(delete, 1) - insert = insert + self._typPadChar + insert = insert + self._padChar if delete > 0: cursor.movePosition(QtMoveLeft, QtKeepAnchor, delete) @@ -2316,15 +2253,63 @@ class TextAutoReplace: 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 + def _determine(self, text: str, pos: int) -> tuple[int, str]: + """Determine what to replace, if anything.""" + t1 = text[pos-1:pos] + t2 = text[pos-2:pos] + t3 = text[pos-3:pos] + t4 = text[pos-4:pos] + if t1 == "": + # Return early if there is nothing to check + return 0, "" + + leading = t2[:1].isspace() + if self._replaceDQuote: + if leading and t2.endswith('"'): + 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 + + if self._replaceSQuote: + if leading and t2.endswith("'"): + 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 + + if self._replaceDash: + if t4 == "----": + return 4, "\u2015" # Horizontal bar + elif t3 == "---": + return 3, "\u2014" # Long dash + elif t2 == "--": + return 2, "\u2013" # Short dash + elif t2 == "\u2013-": + return 2, "\u2014" # Long dash + elif t2 == "\u2014-": + return 2, "\u2015" # Horizontal bar + + if self._replaceDots and t3 == "...": + return 3, "\u2026" # Ellipsis + + if t1 == "\u2028": # Line separator + # This resolves issue #1150 + return 1, "\u2029" # Paragraph separator + + return 0, t1 class GuiDocToolBar(QWidget): diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index 9957b1dc..ed056932 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -81,7 +81,7 @@ def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd): assert qDoc.defaultTextOption().alignment() == QtAlignLeft assert docEditor.verticalScrollBarPolicy() == QtScrollAsNeeded assert docEditor.horizontalScrollBarPolicy() == QtScrollAsNeeded - assert docEditor._autoReplace._typPadChar == nwUnicode.U_NBSP + assert docEditor._autoReplace._padChar == nwUnicode.U_NBSP assert docEditor.docHeader.itemTitle.text() == ( "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 docEditor.verticalScrollBarPolicy() == QtScrollAlwaysOff assert docEditor.horizontalScrollBarPolicy() == QtScrollAlwaysOff - assert docEditor._autoReplace._typPadChar == nwUnicode.U_THNBSP + assert docEditor._autoReplace._padChar == nwUnicode.U_THNBSP assert docEditor.docHeader.itemTitle.text() == "New Scene" # Header From 8dafaa13c05e36d1c03e622d165da23e1f634f81 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 5 Dec 2024 23:01:17 +0100 Subject: [PATCH 3/4] Use slots for some of the often called attributes in the editor --- novelwriter/gui/doceditor.py | 82 +++++++++++++++------------- tests/test_gui/test_gui_doceditor.py | 16 +++--- tests/test_gui/test_gui_guimain.py | 2 +- 3 files changed, 54 insertions(+), 46 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 90b295c2..5bb6a9d8 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -87,6 +87,13 @@ class _SelectAction(Enum): class GuiDocEditor(QPlainTextEdit): """Gui Widget: Main Document Editor""" + __slots__ = ( + "_nwDocument", "_nwItem", "_docChanged", "_docHandle", "_vpMargin", + "_lastEdit", "_lastActive", "_lastFind", "_doReplace", "_autoReplace", + "_completer", "_qDocument", "_keyContext", "_followTag1", "_followTag2", + "_timerDoc", "_wCounterDoc", "_timerSel", "_wCounterSel", + ) + MOVE_KEYS = ( Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Up, Qt.Key.Key_Down, Qt.Key.Key_PageUp, Qt.Key.Key_PageDown @@ -167,38 +174,38 @@ class GuiDocEditor(QPlainTextEdit): self.setAcceptDrops(True) # Custom Shortcuts - self.keyContext = QShortcut(self) - self.keyContext.setKey("Ctrl+.") - self.keyContext.setContext(Qt.ShortcutContext.WidgetShortcut) - self.keyContext.activated.connect(self._openContextFromCursor) + self._keyContext = QShortcut(self) + self._keyContext.setKey("Ctrl+.") + self._keyContext.setContext(Qt.ShortcutContext.WidgetShortcut) + self._keyContext.activated.connect(self._openContextFromCursor) - self.followTag1 = QShortcut(self) - self.followTag1.setKey("Ctrl+Return") - self.followTag1.setContext(Qt.ShortcutContext.WidgetShortcut) - self.followTag1.activated.connect(self._processTag) + self._followTag1 = QShortcut(self) + self._followTag1.setKey("Ctrl+Return") + self._followTag1.setContext(Qt.ShortcutContext.WidgetShortcut) + self._followTag1.activated.connect(self._processTag) - self.followTag2 = QShortcut(self) - self.followTag2.setKey("Ctrl+Enter") - self.followTag2.setContext(Qt.ShortcutContext.WidgetShortcut) - self.followTag2.activated.connect(self._processTag) + self._followTag2 = QShortcut(self) + self._followTag2.setKey("Ctrl+Enter") + self._followTag2.setContext(Qt.ShortcutContext.WidgetShortcut) + self._followTag2.activated.connect(self._processTag) # Set Up Document Word Counter - self.timerDoc = QTimer(self) - self.timerDoc.timeout.connect(self._runDocumentTasks) - self.timerDoc.setInterval(5000) + self._timerDoc = QTimer(self) + self._timerDoc.timeout.connect(self._runDocumentTasks) + self._timerDoc.setInterval(5000) - self.wCounterDoc = BackgroundWordCounter(self) - self.wCounterDoc.setAutoDelete(False) - self.wCounterDoc.signals.countsReady.connect(self._updateDocCounts) + self._wCounterDoc = BackgroundWordCounter(self) + self._wCounterDoc.setAutoDelete(False) + self._wCounterDoc.signals.countsReady.connect(self._updateDocCounts) # Set Up Selection Word Counter - self.timerSel = QTimer(self) - self.timerSel.timeout.connect(self._runSelCounter) - self.timerSel.setInterval(500) + self._timerSel = QTimer(self) + self._timerSel.timeout.connect(self._runSelCounter) + self._timerSel.setInterval(500) - self.wCounterSel = BackgroundWordCounter(self, forSelection=True) - self.wCounterSel.setAutoDelete(False) - self.wCounterSel.signals.countsReady.connect(self._updateSelCounts) + self._wCounterSel = BackgroundWordCounter(self, forSelection=True) + self._wCounterSel.setAutoDelete(False) + self._wCounterSel.signals.countsReady.connect(self._updateSelCounts) # Install Event Filter for Mouse Wheel self.wheelEventFilter = WheelEventFilter(self) @@ -252,8 +259,8 @@ class GuiDocEditor(QPlainTextEdit): self._nwDocument = None self.setReadOnly(True) self.clear() - self.timerDoc.stop() - self.timerSel.stop() + self._timerDoc.stop() + self._timerSel.stop() self._docHandle = None self._lastEdit = 0.0 @@ -301,6 +308,7 @@ class GuiDocEditor(QPlainTextEdit): settings. This function is both called when the editor is created, and when the user changes the main editor preferences. """ + print(len(self.__dict__), self.__dict__) # Auto-Replace self._autoReplace.initSettings() @@ -395,7 +403,7 @@ class GuiDocEditor(QPlainTextEdit): self._lastEdit = time() self._lastActive = time() self._runDocumentTasks() - self.timerDoc.start() + self._timerDoc.start() self.setReadOnly(False) self.updateDocMargins() @@ -1079,8 +1087,8 @@ class GuiDocEditor(QPlainTextEdit): if not self._docChanged: self.setDocumentChanged(removed != 0 or added != 0) - if not self.timerDoc.isActive(): - self.timerDoc.start() + if not self._timerDoc.isActive(): + self._timerDoc.start() if (block := self._qDocument.findBlock(pos)).isValid(): text = block.text() @@ -1222,8 +1230,8 @@ class GuiDocEditor(QPlainTextEdit): if time() - self._lastEdit < 25.0: logger.debug("Running document tasks") - if not self.wCounterDoc.isRunning(): - SHARED.runInThreadPool(self.wCounterDoc) + if not self._wCounterDoc.isRunning(): + SHARED.runInThreadPool(self._wCounterDoc) self.docHeader.setOutline({ block.blockNumber(): block.text() @@ -1255,10 +1263,10 @@ class GuiDocEditor(QPlainTextEdit): information to the footer, and start the selection word counter. """ if self.textCursor().hasSelection(): - if not self.timerSel.isActive(): - self.timerSel.start() + if not self._timerSel.isActive(): + self._timerSel.start() else: - self.timerSel.stop() + self._timerSel.stop() self.docFooter.updateWordCount(0, False) return @@ -1268,11 +1276,11 @@ class GuiDocEditor(QPlainTextEdit): if self._docHandle is None: return - if self.wCounterSel.isRunning(): + if self._wCounterSel.isRunning(): logger.debug("Selection word counter is busy") return - SHARED.runInThreadPool(self.wCounterSel) + SHARED.runInThreadPool(self._wCounterSel) return @@ -1282,7 +1290,7 @@ class GuiDocEditor(QPlainTextEdit): if self._docHandle and self._nwItem: logger.debug("User selected %d words", wCount) self.docFooter.updateWordCount(wCount, True) - self.timerSel.stop() + self._timerSel.stop() return @pyqtSlot() diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index ed056932..7046a420 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -1917,8 +1917,8 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, m threadPool = MockThreadPool() monkeypatch.setattr(QThreadPool, "globalInstance", lambda *a: threadPool) - docEditor.timerDoc.blockSignals(True) - docEditor.timerSel.blockSignals(True) + docEditor._timerDoc.blockSignals(True) + docEditor._timerSel.blockSignals(True) buildTestProject(nwGUI, projPath) @@ -1944,20 +1944,20 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, m # Check that a busy counter is blocked with monkeypatch.context() as mp: - mp.setattr(docEditor.wCounterDoc, "isRunning", lambda *a: True) + mp.setattr(docEditor._wCounterDoc, "isRunning", lambda *a: True) docEditor._runDocumentTasks() assert docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" with monkeypatch.context() as mp: - mp.setattr(docEditor.wCounterSel, "isRunning", lambda *a: True) + mp.setattr(docEditor._wCounterSel, "isRunning", lambda *a: True) docEditor._runSelCounter() assert docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" # Run the full word counter docEditor._runDocumentTasks() - assert threadPool.objectID() == id(docEditor.wCounterDoc) + assert threadPool.objectID() == id(docEditor._wCounterDoc) - docEditor.wCounterDoc.run() + docEditor._wCounterDoc.run() # docEditor._updateDocCounts(cC, wC, pC) assert SHARED.project.tree[C.hSceneDoc]._charCount == cC # type: ignore assert SHARED.project.tree[C.hSceneDoc]._wordCount == wC # type: ignore @@ -1967,9 +1967,9 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, m # Select all text and run the selection word counter docEditor.docAction(nwDocAction.SEL_ALL) docEditor._runSelCounter() - assert threadPool.objectID() == id(docEditor.wCounterSel) + assert threadPool.objectID() == id(docEditor._wCounterSel) - docEditor.wCounterSel.run() + docEditor._wCounterSel.run() assert docEditor.docFooter.wordsText.text() == f"Words: {wC} selected" # qtbot.stop() diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index 9668185f..87287ed8 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -564,7 +564,7 @@ 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) - docEditor.wCounterDoc.run() + docEditor._wCounterDoc.run() # Spell Checking # ============== From 0c0d909dcbd8b35c8d0f5a462cee68737d1d5709 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 5 Dec 2024 23:34:12 +0100 Subject: [PATCH 4/4] Remove debug code --- novelwriter/gui/doceditor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 5bb6a9d8..a4d1293f 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -308,7 +308,6 @@ class GuiDocEditor(QPlainTextEdit): settings. This function is both called when the editor is created, and when the user changes the main editor preferences. """ - print(len(self.__dict__), self.__dict__) # Auto-Replace self._autoReplace.initSettings()