diff --git a/novelwriter/__init__.py b/novelwriter/__init__.py
index 3b49359a6..b3b390285 100644
--- a/novelwriter/__init__.py
+++ b/novelwriter/__init__.py
@@ -73,7 +73,7 @@
# Main Program
##
-# Global config singleton
+# Global config and data singletons
CONFIG = Config()
SHARED = SharedData()
diff --git a/novelwriter/core/tohtml.py b/novelwriter/core/tohtml.py
index 8459ae59b..a4d4bb3f3 100644
--- a/novelwriter/core/tohtml.py
+++ b/novelwriter/core/tohtml.py
@@ -111,7 +111,7 @@ def setReplaceUnicode(self, doReplace: bool) -> None:
def getFullResultSize(self) -> int:
"""Return the size of the full HTML result."""
- return sum([len(x) for x in self._fullHTML])
+ return sum(len(x) for x in self._fullHTML)
def doPreProcessing(self) -> None:
"""Extend the auto-replace to also properly encode some unicode
@@ -122,9 +122,7 @@ def doPreProcessing(self) -> None:
return
def doConvert(self) -> None:
- """Convert the list of text tokens into a HTML document saved
- to _result.
- """
+ """Convert the list of text tokens into an HTML document."""
if self._genMode == self.M_PREVIEW:
htmlTags = { # HTML4 + CSS2 (for Qt)
self.FMT_B_B: "",
@@ -160,9 +158,9 @@ def doConvert(self) -> None:
self._result = ""
- thisPar = []
- parStyle = None
- tmpResult = []
+ para = []
+ pStyle = None
+ lines = []
for tType, tLine, tText, tFormat, tStyle in self._tokens:
@@ -231,69 +229,67 @@ def doConvert(self) -> None:
# Process Text Type
if tType == self.T_EMPTY:
- if parStyle is None:
- parStyle = ""
- if len(thisPar) > 1 and self._cssStyles:
- parClass = " class='break'"
+ if pStyle is None:
+ pStyle = ""
+ if len(para) > 1 and self._cssStyles:
+ pClass = " class='break'"
else:
- parClass = ""
- if len(thisPar) > 0:
- tTemp = "
".join(thisPar)
- tmpResult.append(f"
{tTemp.rstrip()}
\n") - thisPar = [] - parStyle = None + pClass = "" + if len(para) > 0: + tTemp = "{tTemp.rstrip()}
\n") + para = [] + pStyle = None elif tType == self.T_TITLE: tHead = tText.replace(nwHeadFmt.BR, "{tText}
\n") + lines.append(f"{tText}
\n") elif tType == self.T_SKIP: - tmpResult.append(f"\n") + lines.append(f"
\n") elif tType == self.T_TEXT: tTemp = tText - if parStyle is None: - parStyle = hStyle + if pStyle is None: + pStyle = hStyle for xPos, xLen, xFmt in reversed(tFormat): tTemp = tTemp[:xPos] + htmlTags[xFmt] + tTemp[xPos+xLen:] - thisPar.append(stripEscape(tTemp.rstrip())) + para.append(stripEscape(tTemp.rstrip())) elif tType == self.T_SYNOPSIS and self._doSynopsis: - tmpResult.append(self._formatSynopsis(tText)) + lines.append(self._formatSynopsis(tText)) elif tType == self.T_COMMENT and self._doComments: - tmpResult.append(self._formatComments(tText)) + lines.append(self._formatComments(tText)) elif tType == self.T_KEYWORD and self._doKeywords: tTemp = f"
{self._formatKeywords(tText)}
\n" - tmpResult.append(tTemp) - - self._result = "".join(tmpResult) - tmpResult = [] + lines.append(tTemp) + self._result = "".join(lines) if self._genMode != self.M_PREVIEW: self._fullHTML.append(self._result) diff --git a/novelwriter/core/tomd.py b/novelwriter/core/tomd.py index c9859318c..ec1646ad2 100644 --- a/novelwriter/core/tomd.py +++ b/novelwriter/core/tomd.py @@ -65,10 +65,12 @@ def fullMD(self) -> list[str]: ## def setStandardMarkdown(self) -> None: + """Set the converter to use standard Markdown formatting.""" self._genMode = self.M_STD return def setGitHubMarkdown(self) -> None: + """Set the converter to use GitHub Markdown formatting.""" self._genMode = self.M_GH return @@ -78,12 +80,10 @@ def setGitHubMarkdown(self) -> None: def getFullResultSize(self) -> int: """Return the size of the full Markdown result.""" - return sum([len(x) for x in self._fullMD]) + return sum(len(x) for x in self._fullMD) def doConvert(self) -> None: - """Convert the list of text tokens into a HTML document saved - to theResult. - """ + """Convert the list of text tokens into a Markdown document.""" if self._genMode == self.M_STD: # Standard mdTags = { @@ -107,68 +107,65 @@ def doConvert(self) -> None: self._result = "" - thisPar = [] - tmpResult = [] + para = [] + lines = [] for tType, _, tText, tFormat, tStyle in self._tokens: - # Process Text Type if tType == self.T_EMPTY: - if len(thisPar) > 0: - tTemp = (" \n".join(thisPar)).rstrip(" ") - tmpResult.append(f"{tTemp}\n\n") - thisPar = [] + if len(para) > 0: + tTemp = (" \n".join(para)).rstrip(" ") + lines.append(f"{tTemp}\n\n") + para = [] elif tType == self.T_TITLE: tHead = tText.replace(nwHeadFmt.BR, "\n") - tmpResult.append(f"# {tHead}\n\n") + lines.append(f"# {tHead}\n\n") elif tType == self.T_UNNUM: tHead = tText.replace(nwHeadFmt.BR, "\n") - tmpResult.append(f"## {tHead}\n\n") + lines.append(f"## {tHead}\n\n") elif tType == self.T_HEAD1: tHead = tText.replace(nwHeadFmt.BR, "\n") - tmpResult.append(f"# {tHead}\n\n") + lines.append(f"# {tHead}\n\n") elif tType == self.T_HEAD2: tHead = tText.replace(nwHeadFmt.BR, "\n") - tmpResult.append(f"## {tHead}\n\n") + lines.append(f"## {tHead}\n\n") elif tType == self.T_HEAD3: tHead = tText.replace(nwHeadFmt.BR, "\n") - tmpResult.append(f"### {tHead}\n\n") + lines.append(f"### {tHead}\n\n") elif tType == self.T_HEAD4: tHead = tText.replace(nwHeadFmt.BR, "\n") - tmpResult.append(f"#### {tHead}\n\n") + lines.append(f"#### {tHead}\n\n") elif tType == self.T_SEP: - tmpResult.append("%s\n\n" % tText) + lines.append(f"{tText}\n\n") elif tType == self.T_SKIP: - tmpResult.append("\n\n\n") + lines.append("\n\n\n") elif tType == self.T_TEXT: tTemp = tText for xPos, xLen, xFmt in reversed(tFormat): tTemp = tTemp[:xPos] + mdTags[xFmt] + tTemp[xPos+xLen:] - thisPar.append(tTemp.rstrip()) + para.append(tTemp.rstrip()) elif tType == self.T_SYNOPSIS and self._doSynopsis: - locName = self._localLookup("Synopsis") - tmpResult.append(f"**{locName}:** {tText}\n\n") + label = self._localLookup("Synopsis") + lines.append(f"**{label}:** {tText}\n\n") elif tType == self.T_COMMENT and self._doComments: - locName = self._localLookup("Comment") - tmpResult.append(f"**{locName}:** {tText}\n\n") + label = self._localLookup("Comment") + lines.append(f"**{label}:** {tText}\n\n") elif tType == self.T_KEYWORD and self._doKeywords: - tmpResult.append(self._formatKeywords(tText, tStyle)) - - self._result = "".join(tmpResult) - tmpResult = [] + lines.append(self._formatKeywords(tText, tStyle)) + self._result = "".join(lines) self._fullMD.append(self._result) return diff --git a/novelwriter/core/toodt.py b/novelwriter/core/toodt.py index b69af17b3..c4f6b19d9 100644 --- a/novelwriter/core/toodt.py +++ b/novelwriter/core/toodt.py @@ -27,10 +27,10 @@ from __future__ import annotations import logging -from pathlib import Path import xml.etree.ElementTree as ET from hashlib import sha256 +from pathlib import Path from zipfile import ZipFile from datetime import datetime @@ -394,9 +394,9 @@ def doConvert(self) -> None: self.FMT_D_E: "s_", # Strikethrough close format } - thisPar = [] - thisFmt = [] - parStyle = None + fmt = [] + para = [] + pStyle = None for tType, _, tText, tFormat, tStyle in self._tokens: # Styles @@ -429,20 +429,20 @@ def doConvert(self) -> None: # Process Text Types if tType == self.T_EMPTY: - if len(thisPar) > 1 and parStyle is not None: + if len(para) > 1 and pStyle is not None: if self._doJustify: - parStyle.setTextAlign("left") + pStyle.setTextAlign("left") - if len(thisPar) > 0 and parStyle is not None: - tTemp = "\n".join(thisPar) - fTemp = " ".join(thisFmt) + if len(para) > 0 and pStyle is not None: + tTemp = "\n".join(para) + fTemp = " ".join(fmt) tTxt = tTemp.rstrip() tFmt = fTemp[:len(tTxt)] - self._addTextPar("Text_20_body", parStyle, tTxt, tFmt=tFmt) + self._addTextPar("Text_20_body", pStyle, tTxt, tFmt=tFmt) - thisPar = [] - thisFmt = [] - parStyle = None + fmt = [] + para = [] + pStyle = None elif tType == self.T_TITLE: tHead = tText.replace(nwHeadFmt.BR, "\n") @@ -475,8 +475,8 @@ def doConvert(self) -> None: self._addTextPar("Separator", oStyle, "") elif tType == self.T_TEXT: - if parStyle is None: - parStyle = oStyle + if pStyle is None: + pStyle = oStyle tFmt = " "*len(tText) for xPos, xLen, xFmt in tFormat: @@ -484,8 +484,8 @@ def doConvert(self) -> None: tTxt = tText.rstrip() tFmt = tFmt[:len(tTxt)] - thisPar.append(tTxt) - thisFmt.append(tFmt) + para.append(tTxt) + fmt.append(tFmt) elif tType == self.T_SYNOPSIS and self._doSynopsis: tTemp, fTemp = self._formatSynopsis(tText) @@ -501,8 +501,8 @@ def doConvert(self) -> None: return - def closeDocument(self): - """Return the serialised XML document""" + def closeDocument(self) -> None: + """Pack the styles of the XML document.""" # Build the auto-generated styles for styleName, styleObj in self._autoPara.values(): styleObj.packXML(self._xAuto, styleName) @@ -510,7 +510,7 @@ def closeDocument(self): styleObj.packXML(self._xAuto, styleName) return - def saveFlatXML(self, path: str | Path): + def saveFlatXML(self, path: str | Path) -> None: """Save the data to an .fodt file.""" with open(path, mode="wb") as fObj: xml = ET.ElementTree(self._dFlat) @@ -519,7 +519,7 @@ def saveFlatXML(self, path: str | Path): logger.info("Wrote file: %s", path) return - def saveOpenDocText(self, path: str | Path): + def saveOpenDocText(self, path: str | Path) -> None: """Save the data to an .odt file.""" mMani = _mkTag("manifest", "manifest") mVers = _mkTag("manifest", "version") @@ -718,9 +718,9 @@ def _textStyle(self, hFmt: int) -> str: return newName - def _emToCm(self, emVal: float) -> str: + def _emToCm(self, value: float) -> str: """Converts an em value to centimetres.""" - return f"{emVal*2.54/72*self._textSize:.3f}cm" + return f"{value*2.54/72*self._textSize:.3f}cm" ## # Style Elements @@ -1193,56 +1193,56 @@ def setOpacity(self, value: str | None) -> None: # Methods ## - def checkNew(self, refStyle: ODTParagraphStyle) -> bool: + def checkNew(self, style: ODTParagraphStyle) -> bool: """Check if there are new settings in refStyle that differ from those in the current object. """ - for aName, (_, aVal) in refStyle._mAttr.items(): - if aVal is not None and aVal != self._mAttr[aName][1]: + for name, (_, aVal) in style._mAttr.items(): + if aVal is not None and aVal != self._mAttr[name][1]: return True - for aName, (_, aVal) in refStyle._pAttr.items(): - if aVal is not None and aVal != self._pAttr[aName][1]: + for name, (_, aVal) in style._pAttr.items(): + if aVal is not None and aVal != self._pAttr[name][1]: return True - for aName, (_, aVal) in refStyle._tAttr.items(): - if aVal is not None and aVal != self._tAttr[aName][1]: + for name, (_, aVal) in style._tAttr.items(): + if aVal is not None and aVal != self._tAttr[name][1]: return True return False def getID(self) -> str: """Generate a unique ID from the settings.""" - theString = ( + string = ( f"Paragraph:Main:{str(self._mAttr)}:" f"Paragraph:Para:{str(self._pAttr)}:" f"Paragraph:Text:{str(self._tAttr)}:" ) - return sha256(theString.encode()).hexdigest() + return sha256(string.encode()).hexdigest() def packXML(self, xParent: ET.Element, name: str) -> None: """Pack the content into an xml element.""" - theAttr = {} - theAttr[_mkTag("style", "name")] = name - theAttr[_mkTag("style", "family")] = "paragraph" + attr = {} + attr[_mkTag("style", "name")] = name + attr[_mkTag("style", "family")] = "paragraph" for aName, (aNm, aVal) in self._mAttr.items(): if aVal is not None: - theAttr[_mkTag(aNm, aName)] = aVal + attr[_mkTag(aNm, aName)] = aVal - xEntry = ET.SubElement(xParent, _mkTag("style", "style"), attrib=theAttr) + xEntry = ET.SubElement(xParent, _mkTag("style", "style"), attrib=attr) - theAttr = {} + attr = {} for aName, (aNm, aVal) in self._pAttr.items(): if aVal is not None: - theAttr[_mkTag(aNm, aName)] = aVal + attr[_mkTag(aNm, aName)] = aVal - if theAttr: - ET.SubElement(xEntry, _mkTag("style", "paragraph-properties"), attrib=theAttr) + if attr: + ET.SubElement(xEntry, _mkTag("style", "paragraph-properties"), attrib=attr) - theAttr = {} + attr = {} for aName, (aNm, aVal) in self._tAttr.items(): if aVal is not None: - theAttr[_mkTag(aNm, aName)] = aVal + attr[_mkTag(aNm, aName)] = aVal - if theAttr: - ET.SubElement(xEntry, _mkTag("style", "text-properties"), attrib=theAttr) + if attr: + ET.SubElement(xEntry, _mkTag("style", "text-properties"), attrib=attr) return @@ -1307,18 +1307,18 @@ def setStrikeType(self, value: str | None) -> None: def packXML(self, xParent: ET.Element, name: str) -> None: """Pack the content into an xml element.""" - theAttr = {} - theAttr[_mkTag("style", "name")] = name - theAttr[_mkTag("style", "family")] = "text" - xEntry = ET.SubElement(xParent, _mkTag("style", "style"), attrib=theAttr) + attr = {} + attr[_mkTag("style", "name")] = name + attr[_mkTag("style", "family")] = "text" + xEntry = ET.SubElement(xParent, _mkTag("style", "style"), attrib=attr) - theAttr = {} + attr = {} for aName, (aNm, aVal) in self._tAttr.items(): if aVal is not None: - theAttr[_mkTag(aNm, aName)] = aVal + attr[_mkTag(aNm, aName)] = aVal - if theAttr: - ET.SubElement(xEntry, _mkTag("style", "text-properties"), attrib=theAttr) + if attr: + ET.SubElement(xEntry, _mkTag("style", "text-properties"), attrib=attr) return @@ -1368,17 +1368,17 @@ def __init__(self, xRoot: ET.Element) -> None: return - def appendText(self, tText: str) -> None: + def appendText(self, text: str) -> None: """Append text to the XML element. We do this one character at the time in order to be able to process line breaks, tabs and spaces separately. Multiple spaces are concatenated into a single tag, and must therefore be processed separately. """ - tText = stripEscape(tText) + text = stripEscape(text) nSpaces = 0 - self._rawTxt += tText + self._rawTxt += text - for c in tText: + for c in text: if c == " ": nSpaces += 1 continue @@ -1433,17 +1433,17 @@ def appendText(self, tText: str) -> None: return - def appendSpan(self, tText: str, tFmt: str) -> None: + def appendSpan(self, text: str, fmt: str) -> None: """Append a text span to the XML element. The span is always closed since we do not allow nested spans (like Libre Office). Therefore we return to the root element level when we're done processing the text of the span. """ - self._xTail = ET.SubElement(self._xRoot, TAG_SPAN, attrib={TAG_STNM: tFmt}) + self._xTail = ET.SubElement(self._xRoot, TAG_SPAN, attrib={TAG_STNM: fmt}) self._xTail.text = "" # Defaults to None self._xTail.tail = "" # Defaults to None self._nState = X_SPAN_TEXT - self.appendText(tText) + self.appendText(text) self._nState = X_ROOT_TAIL return diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 9b81c7653..525fd179e 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -56,6 +56,7 @@ from novelwriter.constants import nwKeyWords, nwUnicode from novelwriter.core.index import countWords from novelwriter.gui.dochighlight import GuiDocHighlighter +from novelwriter.gui.editordocument import GuiTextDocument from novelwriter.extensions.wheeleventfilter import WheelEventFilter if TYPE_CHECKING: # pragma: no cover @@ -79,6 +80,7 @@ class GuiDocEditor(QPlainTextEdit): loadDocumentTagRequest = pyqtSignal(str, Enum) novelStructureChanged = pyqtSignal() novelItemMetaChanged = pyqtSignal(str) + spellCheckStateChanged = pyqtSignal(bool) def __init__(self, mainGui: GuiMain) -> None: super().__init__(parent=mainGui) @@ -92,18 +94,12 @@ def __init__(self, mainGui: GuiMain) -> None: self._nwItem = None self._docChanged = False # Flag for changed status of document - self._docHandle = None # The handle of the open file - - self._spellCheck = False # Flag for spell checking enabled - self._nonWord = "\"'" # Characters to not include in spell checking + self._docHandle = None # The handle of the open document self._vpMargin = 0 # The editor viewport margin, set during init # Document Variables - self._charCount = 0 # Character count - self._wordCount = 0 # Word count - self._paraCount = 0 # Paragraph count - self._lastEdit = 0 # Time stamp of last edit - self._lastActive = 0.0 # Time stamp of last activity + self._lastEdit = 0.0 # Timestamp of last edit + self._lastActive = 0.0 # Timestamp of last activity self._lastFind = None # Position of the last found search word self._doReplace = False # Switch to temporarily disable auto-replace @@ -120,19 +116,20 @@ def __init__(self, mainGui: GuiMain) -> None: self._typPadBefore = "" self._typPadAfter = "" - # Core Elements and Signals - qDoc = self.document() - qDoc.contentsChange.connect(self._docChange) + # Create Custom Document + self._qDocument = GuiTextDocument(self) + self.setDocument(self._qDocument) + + # Connect Signals + self._qDocument.contentsChange.connect(self._docChange) self.selectionChanged.connect(self._updateSelectedStatus) + self.spellCheckStateChanged.connect(self._qDocument.setSpellCheckState) # Document Title self.docHeader = GuiDocEditHeader(self) self.docFooter = GuiDocEditFooter(self) self.docSearch = GuiDocEditSearch(self) - # Syntax - self.highLight = GuiDocHighlighter(qDoc) - # Context Menu self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self._openContextMenu) @@ -212,7 +209,7 @@ def lastActive(self) -> float: @property def isEmpty(self) -> bool: """Check if the current document is empty.""" - return self.document().isEmpty() + return self._qDocument.isEmpty() ## # Methods @@ -229,10 +226,7 @@ def clearEditor(self) -> None: self.wcTimerSel.stop() self._docHandle = None - self._charCount = 0 - self._wordCount = 0 - self._paraCount = 0 - self._lastEdit = 0 + self._lastEdit = 0.0 self._lastActive = 0.0 self._lastFind = None self._doReplace = False @@ -266,7 +260,7 @@ def updateSyntaxColours(self) -> None: self.docHeader.matchColours() self.docFooter.matchColours() - self.highLight.initHighlighter() + self._qDocument.syntaxHighlighter.initHighlighter() return @@ -275,13 +269,6 @@ def initEditor(self) -> None: settings. This function is both called when the editor is created, and when the user changes the main editor preferences. """ - # Some Constants - self._nonWord = ( - "\"'" - f"{CONFIG.fmtSQuoteOpen}{CONFIG.fmtSQuoteClose}" - f"{CONFIG.fmtDQuoteOpen}{CONFIG.fmtDQuoteClose}" - ) - # Typography if CONFIG.fmtPadThin: self._typPadChar = nwUnicode.U_THNBSP @@ -311,23 +298,21 @@ def initEditor(self) -> None: # Set default text margins # Due to cursor visibility, a part of the margin must be # allocated to the document itself. See issue #1112. - cW = 2*self.cursorWidth() - qDoc = self.document() - qDoc.setDocumentMargin(cW) - self._vpMargin = max(CONFIG.getTextMargin() - cW, 0) + self._qDocument.setDocumentMargin(4) + self._vpMargin = max(CONFIG.getTextMargin() - 4, 0) self.setViewportMargins(self._vpMargin, self._vpMargin, self._vpMargin, self._vpMargin) # Also set the document text options for the document text flow - theOpt = QTextOption() + options = QTextOption() if CONFIG.doJustify: - theOpt.setAlignment(Qt.AlignJustify) + options.setAlignment(Qt.AlignJustify) if CONFIG.showTabsNSpaces: - theOpt.setFlags(theOpt.flags() | QTextOption.ShowTabsAndSpaces) + options.setFlags(options.flags() | QTextOption.ShowTabsAndSpaces) if CONFIG.showLineEndings: - theOpt.setFlags(theOpt.flags() | QTextOption.ShowLineAndParagraphSeparators) + options.setFlags(options.flags() | QTextOption.ShowLineAndParagraphSeparators) - qDoc.setDefaultTextOption(theOpt) + self._qDocument.setDefaultTextOption(options) # Scroll bars if CONFIG.hideVScroll: @@ -377,13 +362,10 @@ def loadText(self, tHandle, tLine=None) -> bool: qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) self._docHandle = tHandle - self.highLight.setHandle(tHandle) - tStart = time() self._allowAutoReplace(False) - self.setPlainText(docText) + self._qDocument.setTextContent(docText, tHandle) self._allowAutoReplace(True) - logger.debug("Document text loaded in %.3f ms", 1000*(time() - tStart)) qApp.processEvents() self._lastEdit = time() @@ -404,14 +386,15 @@ def loadText(self, tHandle, tLine=None) -> bool: self.docFooter.updateLineCount() # This is a hack to fix invisible cursor on an empty document - if self.document().characterCount() <= 1: + if self._qDocument.characterCount() <= 1: self.setPlainText("\n") self.setPlainText("") self.setCursorPosition(0) qApp.processEvents() - self.document().clearUndoRedoStacks() self.setDocumentChanged(False) + self._qDocument.clearUndoRedoStacks() + qApp.restoreOverrideCursor() # Update the status bar @@ -422,12 +405,12 @@ def loadText(self, tHandle, tLine=None) -> bool: def updateTagHighLighting(self) -> None: """Rerun the syntax highlighter on all meta data lines.""" - self.highLight.rehighlightByType(GuiDocHighlighter.BLOCK_META) + self._qDocument.syntaxHighlighter.rehighlightByType(GuiDocHighlighter.BLOCK_META) return def redrawText(self) -> None: """Redraw the text by marking the document content as dirty.""" - self.document().markContentsDirty(0, self.document().characterCount()) + self._qDocument.markContentsDirty(0, self._qDocument.characterCount()) self.updateDocMargins() return @@ -458,14 +441,9 @@ def saveText(self) -> bool: return False docText = self.getText() - cC, wC, pC = countWords(docText) self._updateDocCounts(cC, wC, pC) - self._nwItem.setCharCount(self._charCount) - self._nwItem.setWordCount(self._wordCount) - self._nwItem.setParaCount(self._paraCount) - self.saveCursorPosition() if not self._nwDocument.writeDocument(docText): saveOk = False @@ -561,7 +539,7 @@ def getText(self) -> str: paragraph and line separators though. See: https://doc.qt.io/qt-5/qtextdocument.html#toPlainText """ - text = self.document().toRawText() + text = self._qDocument.toRawText() text = text.replace(nwUnicode.U_LSEP, "\n") # Line separators text = text.replace(nwUnicode.U_PSEP, "\n") # Paragraph separators return text @@ -586,7 +564,7 @@ def setDocumentChanged(self, state: bool) -> bool: def setCursorPosition(self, position: int) -> None: """Move the cursor to a given position in the document.""" - nChars = self.document().characterCount() + nChars = self._qDocument.characterCount() if nChars > 1 and isinstance(position, int): cursor = self.textCursor() cursor.setPosition(minmax(position, 0, nChars-1)) @@ -605,7 +583,7 @@ def saveCursorPosition(self) -> None: def setCursorLine(self, line: int | None) -> None: """Move the cursor to a given line in the document.""" if isinstance(line, int) and line > 0: - block = self.document().findBlockByNumber(line - 1) + block = self._qDocument.findBlockByNumber(line - 1) if block: self.setCursorPosition(block.position()) logger.debug("Cursor moved to line %d", line) @@ -618,29 +596,25 @@ def setCursorLine(self, line: int | None) -> None: def toggleSpellCheck(self, state: bool | None) -> None: """This is the main spell check setting function, and this one should call all other setSpellCheck functions in other classes. - If the spell check mode (theMode) is not defined (None), then - toggle the current status saved in this class. + If the spell check state is not defined (None), then toggle the + current status saved in this class. """ if state is None: - state = not self._spellCheck + state = not SHARED.project.data.spellCheck - if not CONFIG.hasEnchant: - if state: - SHARED.info(self.tr( - "Spell checking requires the package PyEnchant. " - "It does not appear to be installed." - )) + if SHARED.spelling.spellLanguage is None: state = False - if SHARED.spelling.spellLanguage is None: + if state and not CONFIG.hasEnchant: + SHARED.info(self.tr( + "Spell checking requires the package PyEnchant. " + "It does not appear to be installed." + )) state = False - self._spellCheck = state - self.mainGui.mainMenu.setSpellCheck(state) SHARED.project.data.setSpellCheck(state) - self.highLight.setSpellCheck(state) - if state is False: - self.spellCheckDocument() + self.spellCheckStateChanged.emit(state) + self.spellCheckDocument() logger.debug("Spell check is set to '%s'", str(state)) @@ -655,7 +629,7 @@ def spellCheckDocument(self) -> None: logger.debug("Running spell checker") start = time() qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) - self.highLight.rehighlight() + self._qDocument.syntaxHighlighter.rehighlight() qApp.restoreOverrideCursor() logger.debug("Document highlighted in %.3f ms", 1000*(time() - start)) self.statusMessage.emit(self.tr("Spell check complete")) @@ -786,30 +760,30 @@ def insertText(self, insert: str | nwDocInsert) -> bool: goAfter = False if isinstance(insert, str): - theText = insert + text = insert elif isinstance(insert, nwDocInsert): if insert == nwDocInsert.QUOTE_LS: - theText = self._typSQuoteO + text = self._typSQuoteO elif insert == nwDocInsert.QUOTE_RS: - theText = self._typSQuoteC + text = self._typSQuoteC elif insert == nwDocInsert.QUOTE_LD: - theText = self._typDQuoteO + text = self._typDQuoteO elif insert == nwDocInsert.QUOTE_RD: - theText = self._typDQuoteC + text = self._typDQuoteC elif insert == nwDocInsert.SYNOPSIS: - theText = "% Synopsis: " + text = "% Synopsis: " newBlock = True goAfter = True elif insert == nwDocInsert.NEW_PAGE: - theText = "[NEW PAGE]" + text = "[NEW PAGE]" newBlock = True goAfter = False elif insert == nwDocInsert.VSPACE_S: - theText = "[VSPACE]" + text = "[VSPACE]" newBlock = True goAfter = False elif insert == nwDocInsert.VSPACE_M: - theText = "[VSPACE:2]" + text = "[VSPACE:2]" newBlock = True goAfter = False else: @@ -818,42 +792,42 @@ def insertText(self, insert: str | nwDocInsert) -> bool: return False if newBlock: - self.insertNewBlock(theText, defaultAfter=goAfter) + self.insertNewBlock(text, defaultAfter=goAfter) else: - theCursor = self.textCursor() - theCursor.beginEditBlock() - theCursor.insertText(theText) - theCursor.endEditBlock() + cursor = self.textCursor() + cursor.beginEditBlock() + cursor.insertText(text) + cursor.endEditBlock() return True def insertNewBlock(self, text: str, defaultAfter: bool = True) -> bool: """Insert a piece of text on a blank line.""" - theCursor = self.textCursor() - theBlock = theCursor.block() - if not theBlock.isValid(): + cursor = self.textCursor() + block = cursor.block() + if not block.isValid(): logger.error("Not a valid text block") return False - sPos = theBlock.position() - sLen = theBlock.length() + sPos = block.position() + sLen = block.length() - theCursor.beginEditBlock() + cursor.beginEditBlock() if sLen > 1 and defaultAfter: - theCursor.setPosition(sPos + sLen - 1) - theCursor.insertText("\n") + cursor.setPosition(sPos + sLen - 1) + cursor.insertText("\n") else: - theCursor.setPosition(sPos) + cursor.setPosition(sPos) - theCursor.insertText(text) + cursor.insertText(text) if sLen > 1 and not defaultAfter: - theCursor.insertText("\n") + cursor.insertText("\n") - theCursor.endEditBlock() + cursor.endEditBlock() - self.setTextCursor(theCursor) + self.setTextCursor(cursor) return True @@ -913,14 +887,15 @@ def keyPressEvent(self, event: QKeyEvent) -> None: if CONFIG.autoScroll: cPos = self.cursorRect().topLeft().y() super().keyPressEvent(event) + nPos = self.cursorRect().topLeft().y() kMod = event.modifiers() okMod = kMod == Qt.NoModifier or kMod == Qt.ShiftModifier okKey = event.key() not in self.MOVE_KEYS - if okMod and okKey: + if nPos != cPos and okMod and okKey: mPos = CONFIG.autoScrollPos*0.01 * self.viewport().height() if cPos > mPos: vBar = self.verticalScrollBar() - vBar.setValue(vBar.value() + 1) + vBar.setValue(vBar.value() + (1 if nPos > cPos else -1)) else: super().keyPressEvent(event) @@ -946,8 +921,7 @@ def mouseReleaseEvent(self, event: QMouseEvent) -> None: follow tag function. """ if qApp.keyboardModifiers() == Qt.ControlModifier: - theCursor = self.cursorForPosition(event.pos()) - self._followTag(theCursor) + self._followTag(self.cursorForPosition(event.pos())) super().mouseReleaseEvent(event) self.docFooter.updateLineCount() return @@ -994,7 +968,7 @@ def _docChange(self, pos: int, removed: int, added: int) -> None: self.wcTimerDoc.start() if self._doReplace and added == 1: - self._docAutoReplace(self.document().findBlock(pos)) + self._docAutoReplace(self._qDocument.findBlock(pos)) return @@ -1003,100 +977,62 @@ def _openContextMenu(self, pos: QPoint) -> None: """Triggered by right click to open the context menu. Also triggered by the Ctrl+. shortcut. """ - userCursor = self.textCursor() - userSelection = userCursor.hasSelection() - posCursor = self.cursorForPosition(pos) - - mnuContext = QMenu() - - # Follow, Cut, Copy and Paste - # =========================== + uCursor = self.textCursor() + pCursor = self.cursorForPosition(pos) - if self._followTag(cursor=posCursor, loadTag=False): - mnuTag = QAction(self.tr("Follow Tag"), mnuContext) - mnuTag.triggered.connect(lambda: self._followTag(cursor=posCursor)) - mnuContext.addAction(mnuTag) - mnuContext.addSeparator() + ctxMenu = QMenu() - if userSelection: - mnuCut = QAction(self.tr("Cut"), mnuContext) - mnuCut.triggered.connect(lambda: self.docAction(nwDocAction.CUT)) - mnuContext.addAction(mnuCut) + # Follow + if self._followTag(cursor=pCursor, loadTag=False): + aTag = ctxMenu.addAction(self.tr("Follow Tag")) + aTag.triggered.connect(lambda: self._followTag(cursor=pCursor)) + ctxMenu.addSeparator() - mnuCopy = QAction(self.tr("Copy"), mnuContext) - mnuCopy.triggered.connect(lambda: self.docAction(nwDocAction.COPY)) - mnuContext.addAction(mnuCopy) + # Cut, Copy and Paste + if uCursor.hasSelection(): + aCut = ctxMenu.addAction(self.tr("Cut")) + aCut.triggered.connect(lambda: self.docAction(nwDocAction.CUT)) + aCopy = ctxMenu.addAction(self.tr("Copy")) + aCopy.triggered.connect(lambda: self.docAction(nwDocAction.COPY)) - mnuPaste = QAction(self.tr("Paste"), mnuContext) - mnuPaste.triggered.connect(lambda: self.docAction(nwDocAction.PASTE)) - mnuContext.addAction(mnuPaste) - - mnuContext.addSeparator() + aPaste = ctxMenu.addAction(self.tr("Paste")) + aPaste.triggered.connect(lambda: self.docAction(nwDocAction.PASTE)) + ctxMenu.addSeparator() # Selections - # ========== - - mnuSelAll = QAction(self.tr("Select All"), mnuContext) - mnuSelAll.triggered.connect(lambda: self.docAction(nwDocAction.SEL_ALL)) - mnuContext.addAction(mnuSelAll) - - mnuSelWord = QAction(self.tr("Select Word"), mnuContext) - mnuSelWord.triggered.connect( - lambda: self._makePosSelection(QTextCursor.WordUnderCursor, pos) - ) - mnuContext.addAction(mnuSelWord) - - mnuSelPara = QAction(self.tr("Select Paragraph"), mnuContext) - mnuSelPara.triggered.connect( - lambda: self._makePosSelection(QTextCursor.BlockUnderCursor, pos) - ) - mnuContext.addAction(mnuSelPara) + aSAll = ctxMenu.addAction(self.tr("Select All")) + aSAll.triggered.connect(lambda: self.docAction(nwDocAction.SEL_ALL)) + aSWrd = ctxMenu.addAction(self.tr("Select Word")) + aSWrd.triggered.connect(lambda: self._makePosSelection(QTextCursor.WordUnderCursor, pos)) + aSPar = ctxMenu.addAction(self.tr("Select Paragraph")) + aSPar.triggered.connect(lambda: self._makePosSelection(QTextCursor.BlockUnderCursor, pos)) # Spell Checking - # ============== - - posCursor = self.cursorForPosition(pos) - spellCheck = self._spellCheck - theWord = "" - - if posCursor.block().text().startswith("@"): - spellCheck = False - - if spellCheck: - posCursor.select(QTextCursor.WordUnderCursor) - theWord = posCursor.selectedText().strip().strip(self._nonWord) - spellCheck &= theWord != "" - - if spellCheck: - logger.debug("Looking up '%s' in the dictionary", theWord) - spellCheck &= not SHARED.spelling.checkWord(theWord) - - if spellCheck: - mnuContext.addSeparator() - mnuHead = QAction(self.tr("Spelling Suggestion(s)"), mnuContext) - mnuContext.addAction(mnuHead) - - theSuggest = SHARED.spelling.suggestWords(theWord)[:15] - if len(theSuggest) > 0: - for aWord in theSuggest: - mnuWord = QAction("%s %s" % (nwUnicode.U_ENDASH, aWord), mnuContext) - mnuWord.triggered.connect( - lambda thePos, aWord=aWord: self._correctWord(posCursor, aWord) - ) - mnuContext.addAction(mnuWord) - else: - mnuHead = QAction( - "%s %s" % (nwUnicode.U_ENDASH, self.tr("No Suggestions")), mnuContext - ) - mnuContext.addAction(mnuHead) + if SHARED.project.data.spellCheck: + word, cPos, cLen, suggest = self._qDocument.spellErrorAtPos(pCursor.position()) + if word and cPos >= 0 and cLen > 0: + logger.debug("Word '%s' is misspelled", word) + block = pCursor.block() + sCursor = self.textCursor() + sCursor.setPosition(block.position() + cPos) + sCursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, cLen) + if suggest: + ctxMenu.addSeparator() + ctxMenu.addAction(self.tr("Spelling Suggestion(s)")) + for option in suggest[:15]: + aFix = ctxMenu.addAction(f"{nwUnicode.U_ENDASH} {option}") + aFix.triggered.connect( + lambda _, option=option: self._correctWord(sCursor, option) + ) + else: + ctxMenu.addAction("%s %s" % (nwUnicode.U_ENDASH, self.tr("No Suggestions"))) - mnuContext.addSeparator() - mnuAdd = QAction(self.tr("Add Word to Dictionary"), mnuContext) - mnuAdd.triggered.connect(lambda thePos: self._addWord(posCursor)) - mnuContext.addAction(mnuAdd) + ctxMenu.addSeparator() + aAdd = ctxMenu.addAction(self.tr("Add Word to Dictionary")) + aAdd.triggered.connect(lambda: self._addWord(word, block)) - # Open the context menu - mnuContext.exec_(self.viewport().mapToGlobal(pos)) + # Execute the context menu + ctxMenu.exec_(self.viewport().mapToGlobal(pos)) return @@ -1105,24 +1041,23 @@ def _correctWord(self, cursor: QTextCursor, word: str) -> None: """Slot for the spell check context menu triggering the replacement of a word with the word from the dictionary. """ - xPos = cursor.selectionStart() + pos = cursor.selectionStart() cursor.beginEditBlock() cursor.removeSelectedText() cursor.insertText(word) cursor.endEditBlock() - cursor.setPosition(xPos) + cursor.setPosition(pos) self.setTextCursor(cursor) return - @pyqtSlot("QTextCursor") - def _addWord(self, cursor: QTextCursor) -> None: + @pyqtSlot(str, "QTextBlock") + def _addWord(self, word: str, block: QTextBlock) -> None: """Slot for the spell check context menu triggered when the user wants to add a word to the project dictionary. """ - theWord = cursor.selectedText().strip().strip(self._nonWord) - logger.debug("Added '%s' to project dictionary", theWord) - SHARED.spelling.addWord(theWord) - self.highLight.rehighlightBlock(cursor.block()) + logger.debug("Added '%s' to project dictionary", word) + SHARED.spelling.addWord(word) + self._qDocument.syntaxHighlighter.rehighlightBlock(block) return @pyqtSlot() @@ -1137,9 +1072,9 @@ def _runDocCounter(self) -> None: logger.debug("Word counter is busy") return - if time() - self._lastEdit < 5 * self.wcInterval: + if time() - self._lastEdit < 5.0 * self.wcInterval: logger.debug("Running word counter") - self.mainGui.threadPool.start(self.wCounterDoc) + SHARED.runInThreadPool(self.wCounterDoc) return @@ -1151,10 +1086,6 @@ def _updateDocCounts(self, cCount: int, wCount: int, pCount: int) -> None: logger.debug("Updating word count") - self._charCount = cCount - self._wordCount = wCount - self._paraCount = pCount - self._nwItem.setCharCount(cCount) self._nwItem.setWordCount(wCount) self._nwItem.setParaCount(pCount) @@ -1174,12 +1105,10 @@ def _updateSelectedStatus(self) -> None: if not self.wcTimerSel.isActive(): self.wcTimerSel.start() self.docFooter.setHasSelection(True) - else: self.wcTimerSel.stop() self.docFooter.setHasSelection(False) self.docFooter.updateCounts() - return @pyqtSlot() @@ -1192,7 +1121,7 @@ def _runSelCounter(self) -> None: logger.debug("Selection word counter is busy") return - self.mainGui.threadPool.start(self.wCounterSel) + SHARED.runInThreadPool(self.wCounterSel) return @@ -1214,9 +1143,9 @@ def _updateSelCounts(self, cCount: int, wCount: int, pCount: int) -> None: def beginSearch(self) -> None: """Set the selected text as the search text.""" - theCursor = self.textCursor() - if theCursor.hasSelection(): - self.docSearch.setSearchText(theCursor.selectedText()) + cursor = self.textCursor() + if cursor.hasSelection(): + self.docSearch.setSearchText(cursor.selectedText()) else: self.docSearch.setSearchText(None) resS, _ = self.findAllOccurences() @@ -1254,8 +1183,8 @@ def findNext(self, goBack: bool = False) -> None: self.beginSearch() return - theCursor = self.textCursor() - resIdx = bisect.bisect_left(resS, theCursor.position()) + cursor = self.textCursor() + resIdx = bisect.bisect_left(resS, cursor.position()) doLoop = self.docSearch.doLoop maxIdx = len(resS) - 1 @@ -1276,9 +1205,9 @@ def findNext(self, goBack: bool = False) -> None: else: resIdx = 0 if doLoop else maxIdx - theCursor.setPosition(resS[resIdx], QTextCursor.MoveAnchor) - theCursor.setPosition(resE[resIdx], QTextCursor.KeepAnchor) - self.setTextCursor(theCursor) + cursor.setPosition(resS[resIdx], QTextCursor.MoveAnchor) + cursor.setPosition(resE[resIdx], QTextCursor.KeepAnchor) + self.setTextCursor(cursor) self.docFooter.updateLineCount() self.docSearch.setResultCount(resIdx + 1, len(resS)) @@ -1292,14 +1221,14 @@ def findAllOccurences(self) -> tuple[list[int], list[int]]: """ resS = [] resE = [] - theCursor = self.textCursor() - hasSelection = theCursor.hasSelection() + cursor = self.textCursor() + hasSelection = cursor.hasSelection() if hasSelection: - origA = theCursor.selectionStart() - origB = theCursor.selectionEnd() + origA = cursor.selectionStart() + origB = cursor.selectionEnd() else: - origA = theCursor.position() - origB = theCursor.position() + origA = cursor.position() + origB = cursor.position() findOpt = QTextDocument.FindFlag(0) if self.docSearch.isCaseSense: @@ -1308,27 +1237,27 @@ def findAllOccurences(self) -> tuple[list[int], list[int]]: findOpt |= QTextDocument.FindWholeWords searchFor = self.docSearch.getSearchObject() - theCursor.setPosition(0) - self.setTextCursor(theCursor) + cursor.setPosition(0) + self.setTextCursor(cursor) # Search up to a maximum of 1000, and make sure certain special - # searches like a regex search for .* turns into an infinite loop + # searches like a regex search for .* don't loop infinitely while self.find(searchFor, findOpt) and len(resE) <= 1000: - theCursor = self.textCursor() - if theCursor.hasSelection(): - resS.append(theCursor.selectionStart()) - resE.append(theCursor.selectionEnd()) + cursor = self.textCursor() + if cursor.hasSelection(): + resS.append(cursor.selectionStart()) + resE.append(cursor.selectionEnd()) else: logger.warning("The search returned an empty result") break if hasSelection: - theCursor.setPosition(origA, QTextCursor.MoveAnchor) - theCursor.setPosition(origB, QTextCursor.KeepAnchor) + cursor.setPosition(origA, QTextCursor.MoveAnchor) + cursor.setPosition(origB, QTextCursor.KeepAnchor) else: - theCursor.setPosition(origA) + cursor.setPosition(origA) - self.setTextCursor(theCursor) + self.setTextCursor(cursor) return resS, resE @@ -1346,24 +1275,24 @@ def replaceNext(self) -> None: self.beginSearch() return - theCursor = self.textCursor() - if not theCursor.hasSelection(): + cursor = self.textCursor() + if not cursor.hasSelection(): # We have no text selected at all, so just make this a # regular find next call. self.findNext() return - if self._lastFind is None and theCursor.hasSelection(): + if self._lastFind is None and cursor.hasSelection(): # If we have a selection but no search, it may have been the # text we triggered the search with, in which case we search # again from the beginning of that selection to make sure we # have a valid result. - sPos = theCursor.selectionStart() - theCursor.clearSelection() - theCursor.setPosition(sPos) - self.setTextCursor(theCursor) + sPos = cursor.selectionStart() + cursor.clearSelection() + cursor.setPosition(sPos) + self.setTextCursor(cursor) self.findNext() - theCursor = self.textCursor() + cursor = self.textCursor() if self._lastFind is None: # In case the above didn't find a result, we give up here. @@ -1373,26 +1302,26 @@ def replaceNext(self) -> None: replWith = self.docSearch.replaceText if self.docSearch.doMatchCap: - replWith = transferCase(theCursor.selectedText(), replWith) + replWith = transferCase(cursor.selectedText(), replWith) # Make sure the selected text was selected by an actual find # call, and not the user. try: - isFind = self._lastFind[0] == theCursor.selectionStart() - isFind &= self._lastFind[1] == theCursor.selectionEnd() + isFind = self._lastFind[0] == cursor.selectionStart() + isFind &= self._lastFind[1] == cursor.selectionEnd() except Exception: isFind = False if isFind: - theCursor.beginEditBlock() - theCursor.removeSelectedText() - theCursor.insertText(replWith) - theCursor.endEditBlock() - theCursor.setPosition(theCursor.selectionEnd()) - self.setTextCursor(theCursor) + cursor.beginEditBlock() + cursor.removeSelectedText() + cursor.insertText(replWith) + cursor.endEditBlock() + cursor.setPosition(cursor.selectionEnd()) + self.setTextCursor(cursor) logger.debug( "Replaced occurrence of '%s' with '%s' on line %d", - searchFor, replWith, theCursor.blockNumber() + searchFor, replWith, cursor.blockNumber() ) else: logger.error("The selected text is not a search result, skipping replace") @@ -1410,40 +1339,40 @@ def _toggleFormat(self, fLen: int, fChar: str) -> bool: If more than one block is selected, the formatting is applied to the first block. """ - theCursor = self._autoSelect() - if not theCursor.hasSelection(): + cursor = self._autoSelect() + if not cursor.hasSelection(): logger.warning("No selection made, nothing to do") return False - posS = theCursor.selectionStart() - posE = theCursor.selectionEnd() + posS = cursor.selectionStart() + posE = cursor.selectionEnd() - blockS = self.document().findBlock(posS) - blockE = self.document().findBlock(posE) + blockS = self._qDocument.findBlock(posS) + blockE = self._qDocument.findBlock(posE) if blockS != blockE: posE = blockS.position() + blockS.length() - 1 - theCursor.clearSelection() - theCursor.setPosition(posS, QTextCursor.MoveAnchor) - theCursor.setPosition(posE, QTextCursor.KeepAnchor) - self.setTextCursor(theCursor) + cursor.clearSelection() + cursor.setPosition(posS, QTextCursor.MoveAnchor) + cursor.setPosition(posE, QTextCursor.KeepAnchor) + self.setTextCursor(cursor) numB = 0 for n in range(fLen): - if self.document().characterAt(posS-n-1) == fChar: + if self._qDocument.characterAt(posS-n-1) == fChar: numB += 1 else: break numA = 0 for n in range(fLen): - if self.document().characterAt(posE+n) == fChar: + if self._qDocument.characterAt(posE+n) == fChar: numA += 1 else: break if fLen == min(numA, numB): - self._clearSurrounding(theCursor, fLen) + self._clearSurrounding(cursor, fLen) else: self._wrapSelection(fChar*fLen) @@ -1479,52 +1408,51 @@ def _wrapSelection(self, before: str, after: str | None = None) -> bool: if after is None: after = before - theCursor = self._autoSelect() - if not theCursor.hasSelection(): + cursor = self._autoSelect() + if not cursor.hasSelection(): logger.warning("No selection made, nothing to do") return False - posS = theCursor.selectionStart() - posE = theCursor.selectionEnd() + posS = cursor.selectionStart() + posE = cursor.selectionEnd() - qDoc = self.document() - blockS = qDoc.findBlock(posS) - blockE = qDoc.findBlock(posE) + blockS = self._qDocument.findBlock(posS) + blockE = self._qDocument.findBlock(posE) if blockS != blockE: posE = blockS.position() + blockS.length() - 1 - theCursor.clearSelection() - theCursor.beginEditBlock() - theCursor.setPosition(posE) - theCursor.insertText(after) - theCursor.setPosition(posS) - theCursor.insertText(before) - theCursor.endEditBlock() + cursor.clearSelection() + cursor.beginEditBlock() + cursor.setPosition(posE) + cursor.insertText(after) + cursor.setPosition(posS) + cursor.insertText(before) + cursor.endEditBlock() - theCursor.setPosition(posE + len(before), QTextCursor.MoveAnchor) - theCursor.setPosition(posS + len(before), QTextCursor.KeepAnchor) - self.setTextCursor(theCursor) + cursor.setPosition(posE + len(before), QTextCursor.MoveAnchor) + cursor.setPosition(posS + len(before), QTextCursor.KeepAnchor) + self.setTextCursor(cursor) return True def _replaceQuotes(self, sQuote: str, oQuote: str, cQuote: str) -> bool: """Replace all straight quotes in the selected text.""" - theCursor = self.textCursor() - if not theCursor.hasSelection(): + cursor = self.textCursor() + if not cursor.hasSelection(): SHARED.error(self.tr("Please select some text before calling replace quotes.")) return False - posS = theCursor.selectionStart() - posE = theCursor.selectionEnd() + posS = cursor.selectionStart() + posE = cursor.selectionEnd() closeCheck = ( " ", "\n", nwUnicode.U_LSEP, nwUnicode.U_PSEP ) self._allowAutoReplace(False) for posC in range(posS, posE+1): - theCursor.setPosition(posC) - theCursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 2) - selText = theCursor.selectedText() + cursor.setPosition(posC) + cursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 2) + selText = cursor.selectedText() nS = len(selText) if nS == 2: @@ -1539,18 +1467,18 @@ def _replaceQuotes(self, sQuote: str, oQuote: str, cQuote: str) -> bool: if cC != sQuote: continue - theCursor.clearSelection() - theCursor.setPosition(posC) + cursor.clearSelection() + cursor.setPosition(posC) if pC in closeCheck: - theCursor.beginEditBlock() - theCursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 1) - theCursor.insertText(oQuote) - theCursor.endEditBlock() + cursor.beginEditBlock() + cursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 1) + cursor.insertText(oQuote) + cursor.endEditBlock() else: - theCursor.beginEditBlock() - theCursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 1) - theCursor.insertText(cQuote) - theCursor.endEditBlock() + cursor.beginEditBlock() + cursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 1) + cursor.insertText(cQuote) + cursor.endEditBlock() self._allowAutoReplace(True) @@ -1558,148 +1486,147 @@ def _replaceQuotes(self, sQuote: str, oQuote: str, cQuote: str) -> bool: def _formatBlock(self, action: nwDocAction) -> bool: """Change the block format of the block under the cursor.""" - theCursor = self.textCursor() - theBlock = theCursor.block() - if not theBlock.isValid(): + cursor = self.textCursor() + block = cursor.block() + if not block.isValid(): logger.debug("Invalid block selected for action '%s'", str(action)) return False # Remove existing format first, if any - theText = theBlock.text() - hasText = len(theText) > 0 - if theText.startswith("@"): + setText = block.text() + hasText = len(setText) > 0 + if setText.startswith("@"): logger.error("Cannot apply block format to keyword/value line") return False - elif theText.startswith("% "): - newText = theText[2:] + elif setText.startswith("% "): + newText = setText[2:] cOffset = 2 if action == nwDocAction.BLOCK_COM: action = nwDocAction.BLOCK_TXT - elif theText.startswith("%"): - newText = theText[1:] + elif setText.startswith("%"): + newText = setText[1:] cOffset = 1 if action == nwDocAction.BLOCK_COM: action = nwDocAction.BLOCK_TXT - elif theText.startswith("# "): - newText = theText[2:] + elif setText.startswith("# "): + newText = setText[2:] cOffset = 2 - elif theText.startswith("## "): - newText = theText[3:] + elif setText.startswith("## "): + newText = setText[3:] cOffset = 3 - elif theText.startswith("### "): - newText = theText[4:] + elif setText.startswith("### "): + newText = setText[4:] cOffset = 4 - elif theText.startswith("#### "): - newText = theText[5:] + elif setText.startswith("#### "): + newText = setText[5:] cOffset = 5 - elif theText.startswith("#! "): - newText = theText[3:] + elif setText.startswith("#! "): + newText = setText[3:] cOffset = 3 - elif theText.startswith("##! "): - newText = theText[4:] + elif setText.startswith("##! "): + newText = setText[4:] cOffset = 4 - elif theText.startswith(">> "): - newText = theText[3:] + elif setText.startswith(">> "): + newText = setText[3:] cOffset = 3 - elif theText.startswith("> ") and action != nwDocAction.INDENT_R: - newText = theText[2:] + elif setText.startswith("> ") and action != nwDocAction.INDENT_R: + newText = setText[2:] cOffset = 2 - elif theText.startswith(">>"): - newText = theText[2:] + elif setText.startswith(">>"): + newText = setText[2:] cOffset = 2 - elif theText.startswith(">") and action != nwDocAction.INDENT_R: - newText = theText[1:] + elif setText.startswith(">") and action != nwDocAction.INDENT_R: + newText = setText[1:] cOffset = 1 else: - newText = theText + newText = setText cOffset = 0 # Also remove formatting tags at the end - if theText.endswith(" <<"): + if setText.endswith(" <<"): newText = newText[:-3] - elif theText.endswith(" <") and action != nwDocAction.INDENT_L: + elif setText.endswith(" <") and action != nwDocAction.INDENT_L: newText = newText[:-2] - elif theText.endswith("<<"): + elif setText.endswith("<<"): newText = newText[:-2] - elif theText.endswith("<") and action != nwDocAction.INDENT_L: + elif setText.endswith("<") and action != nwDocAction.INDENT_L: newText = newText[:-1] # Apply new format if action == nwDocAction.BLOCK_COM: - theText = "% "+newText + setText = "% "+newText cOffset -= 2 elif action == nwDocAction.BLOCK_H1: - theText = "# "+newText + setText = "# "+newText cOffset -= 2 elif action == nwDocAction.BLOCK_H2: - theText = "## "+newText + setText = "## "+newText cOffset -= 3 elif action == nwDocAction.BLOCK_H3: - theText = "### "+newText + setText = "### "+newText cOffset -= 4 elif action == nwDocAction.BLOCK_H4: - theText = "#### "+newText + setText = "#### "+newText cOffset -= 5 elif action == nwDocAction.BLOCK_TTL: - theText = "#! "+newText + setText = "#! "+newText cOffset -= 3 elif action == nwDocAction.BLOCK_UNN: - theText = "##! "+newText + setText = "##! "+newText cOffset -= 4 elif action == nwDocAction.ALIGN_L: - theText = newText+" <<" + setText = newText+" <<" elif action == nwDocAction.ALIGN_C: - theText = ">> "+newText+" <<" + setText = ">> "+newText+" <<" cOffset -= 3 elif action == nwDocAction.ALIGN_R: - theText = ">> "+newText + setText = ">> "+newText cOffset -= 3 elif action == nwDocAction.INDENT_L: - theText = "> "+newText + setText = "> "+newText cOffset -= 2 elif action == nwDocAction.INDENT_R: - theText = newText+" <" + setText = newText+" <" elif action == nwDocAction.BLOCK_TXT: - theText = newText + setText = newText else: logger.error("Unknown or unsupported block format requested: '%s'", str(action)) return False # Replace the block text - theCursor.beginEditBlock() - posO = theCursor.position() - theCursor.select(QTextCursor.BlockUnderCursor) - posS = theCursor.selectionStart() - theCursor.removeSelectedText() - theCursor.setPosition(posS) + cursor.beginEditBlock() + posO = cursor.position() + cursor.select(QTextCursor.BlockUnderCursor) + posS = cursor.selectionStart() + cursor.removeSelectedText() + cursor.setPosition(posS) if posS > 0 and hasText: # If the block already had text, we must insert a new block # first before we can add back the text to it. - theCursor.insertBlock() + cursor.insertBlock() - theCursor.insertText(theText) + cursor.insertText(setText) if posO - cOffset >= 0: - theCursor.setPosition(posO - cOffset) + cursor.setPosition(posO - cOffset) - theCursor.endEditBlock() - self.setTextCursor(theCursor) + cursor.endEditBlock() + self.setTextCursor(cursor) return True def _removeInParLineBreaks(self) -> None: """Strip line breaks within paragraphs in the selected text.""" - theCursor = self.textCursor() - theDoc = self.document() + cursor = self.textCursor() iS = 0 - iE = theDoc.blockCount() - 1 + iE = self._qDocument.blockCount() - 1 rS = 0 - rE = theDoc.characterCount() - if theCursor.hasSelection(): - sBlock = theDoc.findBlock(theCursor.selectionStart()) - eBlock = theDoc.findBlock(theCursor.selectionEnd()) + rE = self._qDocument.characterCount() + if cursor.hasSelection(): + sBlock = self._qDocument.findBlock(cursor.selectionStart()) + eBlock = self._qDocument.findBlock(cursor.selectionEnd()) iS = sBlock.blockNumber() iE = eBlock.blockNumber() rS = sBlock.position() @@ -1709,7 +1636,7 @@ def _removeInParLineBreaks(self) -> None: currPar = [] cleanText = "" for i in range(iS, iE+1): - cBlock = theDoc.findBlockByNumber(i) + cBlock = self._qDocument.findBlockByNumber(i) cText = cBlock.text() if cText.strip() == "": if currPar: @@ -1726,12 +1653,12 @@ def _removeInParLineBreaks(self) -> None: cleanText += " ".join(currPar) + "\n\n" # Replace the text with the cleaned up text - theCursor.beginEditBlock() - theCursor.clearSelection() - theCursor.setPosition(rS) - theCursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, rE-rS) - theCursor.insertText(cleanText.rstrip() + "\n") - theCursor.endEditBlock() + cursor.beginEditBlock() + cursor.clearSelection() + cursor.setPosition(rS) + cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, rE-rS) + cursor.insertText(cleanText.rstrip() + "\n") + cursor.endEditBlock() return @@ -1749,37 +1676,37 @@ def _followTag(self, cursor: QTextCursor | None = None, loadTag: bool = True) -> if cursor is None: cursor = self.textCursor() - theBlock = cursor.block() - theText = theBlock.text() + block = cursor.block() + text = block.text() - if len(theText) == 0: + if len(text) == 0: return False - if theText.startswith("@"): + if text.startswith("@"): - isGood, tBits, tPos = SHARED.project.index.scanThis(theText) + isGood, tBits, tPos = SHARED.project.index.scanThis(text) if not isGood: return False - theTag = "" - cPos = cursor.selectionStart() - theBlock.position() + tag = "" + cPos = cursor.selectionStart() - block.position() for sTag, sPos in zip(reversed(tBits), reversed(tPos)): if cPos >= sPos: # The cursor is between the start of two tags if cPos <= sPos + len(sTag): # The cursor is inside or at the edge of the tag - theTag = sTag + tag = sTag break - if not theTag or theTag.startswith("@"): + if not tag or tag.startswith("@"): # The keyword cannot be looked up, so we ignore that return False if loadTag: - logger.debug("Attempting to follow tag '%s'", theTag) - self.loadDocumentTagRequest.emit(theTag, nwDocMode.VIEW) + logger.debug("Attempting to follow tag '%s'", tag) + self.loadDocumentTagRequest.emit(tag, nwDocMode.VIEW) else: - logger.debug("Potential tag '%s'", theTag) + logger.debug("Potential tag '%s'", tag) return True @@ -1795,94 +1722,93 @@ def _docAutoReplace(self, block: QTextBlock) -> None: if not block.isValid(): return - theText = block.text() - theCursor = self.textCursor() - thePos = theCursor.positionInBlock() - theLen = len(theText) + text = block.text() + cursor = self.textCursor() + tPos = cursor.positionInBlock() + tLen = len(text) - if theLen < 1 or thePos-1 > theLen: + if tLen < 1 or tPos-1 > tLen: return - theOne = theText[thePos-1:thePos] - theTwo = theText[thePos-2:thePos] - theThree = theText[thePos-3:thePos] + tOne = text[tPos-1:tPos] + tTwo = text[tPos-2:tPos] + tThree = text[tPos-3:tPos] - if not theOne: - # Sorry, Neo and Zathras + if not tOne: return nDelete = 0 - tInsert = theOne + tInsert = tOne - if self._typRepDQuote and theTwo[:1].isspace() and theTwo.endswith('"'): + if self._typRepDQuote and tTwo[:1].isspace() and tTwo.endswith('"'): nDelete = 1 tInsert = self._typDQuoteO - elif self._typRepDQuote and theOne == '"': + elif self._typRepDQuote and tOne == '"': nDelete = 1 - if thePos == 1: + if tPos == 1: tInsert = self._typDQuoteO - elif thePos == 2 and theTwo == '>"': + elif tPos == 2 and tTwo == '>"': tInsert = self._typDQuoteO - elif thePos == 3 and theThree == '>>"': + elif tPos == 3 and tThree == '>>"': tInsert = self._typDQuoteO else: tInsert = self._typDQuoteC - elif self._typRepSQuote and theTwo[:1].isspace() and theTwo.endswith("'"): + elif self._typRepSQuote and tTwo[:1].isspace() and tTwo.endswith("'"): nDelete = 1 tInsert = self._typSQuoteO - elif self._typRepSQuote and theOne == "'": + elif self._typRepSQuote and tOne == "'": nDelete = 1 - if thePos == 1: + if tPos == 1: tInsert = self._typSQuoteO - elif thePos == 2 and theTwo == ">'": + elif tPos == 2 and tTwo == ">'": tInsert = self._typSQuoteO - elif thePos == 3 and theThree == ">>'": + elif tPos == 3 and tThree == ">>'": tInsert = self._typSQuoteO else: tInsert = self._typSQuoteC - elif self._typRepDash and theThree == "---": + elif self._typRepDash and tThree == "---": nDelete = 3 tInsert = nwUnicode.U_EMDASH - elif self._typRepDash and theTwo == "--": + elif self._typRepDash and tTwo == "--": nDelete = 2 tInsert = nwUnicode.U_ENDASH - elif self._typRepDash and theTwo == nwUnicode.U_ENDASH + "-": + elif self._typRepDash and tTwo == nwUnicode.U_ENDASH + "-": nDelete = 2 tInsert = nwUnicode.U_EMDASH - elif self._typRepDots and theThree == "...": + elif self._typRepDots and tThree == "...": nDelete = 3 tInsert = nwUnicode.U_HELLIP - elif theOne == nwUnicode.U_LSEP: + elif tOne == nwUnicode.U_LSEP: # This resolves issue #1150 nDelete = 1 tInsert = nwUnicode.U_PSEP tCheck = tInsert if self._typPadBefore and tCheck in self._typPadBefore: - if self._allowSpaceBeforeColon(theText, tCheck): + if self._allowSpaceBeforeColon(text, tCheck): nDelete = max(nDelete, 1) - chkPos = thePos - nDelete - 1 - if chkPos >= 0 and theText[chkPos].isspace(): + chkPos = tPos - nDelete - 1 + if chkPos >= 0 and text[chkPos].isspace(): # Strip existing space before inserting a new (#1061) nDelete += 1 tInsert = self._typPadChar + tInsert if self._typPadAfter and tCheck in self._typPadAfter: - if self._allowSpaceBeforeColon(theText, tCheck): + if self._allowSpaceBeforeColon(text, tCheck): nDelete = max(nDelete, 1) tInsert = tInsert + self._typPadChar if nDelete > 0: - theCursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, nDelete) - theCursor.insertText(tInsert) + cursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, nDelete) + cursor.insertText(tInsert) return @@ -1904,60 +1830,57 @@ def _autoSelect(self) -> QTextCursor: """Return a cursor which may or may not have a selection based on user settings and document action. """ - theCursor = self.textCursor() - if CONFIG.autoSelect and not theCursor.hasSelection(): - theCursor.select(QTextCursor.WordUnderCursor) - posS = theCursor.selectionStart() - posE = theCursor.selectionEnd() + cursor = self.textCursor() + if CONFIG.autoSelect and not cursor.hasSelection(): + cursor.select(QTextCursor.WordUnderCursor) + posS = cursor.selectionStart() + posE = cursor.selectionEnd() # Underscore counts as a part of the word, so check that the # selection isn't wrapped in italics markers. reSelect = False - qDoc = self.document() - if qDoc.characterAt(posS) == "_": + if self._qDocument.characterAt(posS) == "_": posS += 1 reSelect = True - if qDoc.characterAt(posE) == "_": + if self._qDocument.characterAt(posE) == "_": posE -= 1 reSelect = True if reSelect: - theCursor.clearSelection() - theCursor.setPosition(posS, QTextCursor.MoveAnchor) - theCursor.setPosition(posE-1, QTextCursor.KeepAnchor) + cursor.clearSelection() + cursor.setPosition(posS, QTextCursor.MoveAnchor) + cursor.setPosition(posE-1, QTextCursor.KeepAnchor) - self.setTextCursor(theCursor) + self.setTextCursor(cursor) - return theCursor + return cursor def _makeSelection(self, mode: QTextCursor.SelectionType) -> None: - """Select text based on a selection mode.""" - theCursor = self.textCursor() - theCursor.clearSelection() - theCursor.select(mode) + """Select text based on selection mode.""" + cursor = self.textCursor() + cursor.clearSelection() + cursor.select(mode) if mode == QTextCursor.WordUnderCursor: - theCursor = self._autoSelect() + cursor = self._autoSelect() elif mode == QTextCursor.BlockUnderCursor: # This selection mode also selects the preceding paragraph # separator, which we want to avoid. - posS = theCursor.selectionStart() - posE = theCursor.selectionEnd() - selTxt = theCursor.selectedText() + posS = cursor.selectionStart() + posE = cursor.selectionEnd() + selTxt = cursor.selectedText() if selTxt.startswith(nwUnicode.U_PSEP): - theCursor.setPosition(posS+1, QTextCursor.MoveAnchor) - theCursor.setPosition(posE, QTextCursor.KeepAnchor) + cursor.setPosition(posS+1, QTextCursor.MoveAnchor) + cursor.setPosition(posE, QTextCursor.KeepAnchor) - self.setTextCursor(theCursor) + self.setTextCursor(cursor) return def _makePosSelection(self, mode: QTextCursor.SelectionType, pos: QPoint) -> None: - """Wrapper function to select text based on selection mode, but - first move cursor to given position. - """ - theCursor = self.cursorForPosition(pos) - self.setTextCursor(theCursor) + """Select text based on selection mode, but first move cursor.""" + cursor = self.cursorForPosition(pos) + self.setTextCursor(cursor) self._makeSelection(mode) return @@ -2000,11 +1923,11 @@ def run(self) -> None: """ self._isRunning = True if self._forSelection: - theText = self._docEditor.textCursor().selectedText() + text = self._docEditor.textCursor().selectedText() else: - theText = self._docEditor.getText() + text = self._docEditor.getText() - cC, wC, pC = countWords(theText) + cC, wC, pC = countWords(text) self.signals.countsReady.emit(cC, wC, pC) self._isRunning = False @@ -2035,7 +1958,6 @@ def __init__(self, docEditor: GuiDocEditor) -> None: logger.debug("Create: GuiDocEditSearch") self.docEditor = docEditor - self.mainGui = docEditor.mainGui self.repVisible = False self.isCaseSense = CONFIG.searchCase @@ -2455,18 +2377,18 @@ def __init__(self, docEditor: GuiDocEditor) -> None: self.setAutoFillBackground(True) # Title Label - self.theTitle = QLabel() - self.theTitle.setText("") - self.theTitle.setIndent(0) - self.theTitle.setMargin(0) - self.theTitle.setContentsMargins(0, 0, 0, 0) - self.theTitle.setAutoFillBackground(True) - self.theTitle.setAlignment(Qt.AlignHCenter | Qt.AlignTop) - self.theTitle.setFixedHeight(fPx) - - lblFont = self.theTitle.font() + self.itemTitle = QLabel() + self.itemTitle.setText("") + self.itemTitle.setIndent(0) + self.itemTitle.setMargin(0) + self.itemTitle.setContentsMargins(0, 0, 0, 0) + self.itemTitle.setAutoFillBackground(True) + self.itemTitle.setAlignment(Qt.AlignHCenter | Qt.AlignTop) + self.itemTitle.setFixedHeight(fPx) + + lblFont = self.itemTitle.font() lblFont.setPointSizeF(0.9*SHARED.theme.fontPointSize) - self.theTitle.setFont(lblFont) + self.itemTitle.setFont(lblFont) # Buttons self.editButton = QToolButton(self) @@ -2510,7 +2432,7 @@ def __init__(self, docEditor: GuiDocEditor) -> None: self.outerBox.setSpacing(hSp) self.outerBox.addWidget(self.editButton, 0) self.outerBox.addWidget(self.searchButton, 0) - self.outerBox.addWidget(self.theTitle, 1) + self.outerBox.addWidget(self.itemTitle, 1) self.outerBox.addWidget(self.minmaxButton, 0) self.outerBox.addWidget(self.closeButton, 0) self.setLayout(self.outerBox) @@ -2557,13 +2479,13 @@ def matchColours(self) -> None: """Update the colours of the widget to match those of the syntax theme rather than the main GUI. """ - thePalette = QPalette() - thePalette.setColor(QPalette.Window, QColor(*SHARED.theme.colBack)) - thePalette.setColor(QPalette.WindowText, QColor(*SHARED.theme.colText)) - thePalette.setColor(QPalette.Text, QColor(*SHARED.theme.colText)) + palette = QPalette() + palette.setColor(QPalette.Window, QColor(*SHARED.theme.colBack)) + palette.setColor(QPalette.WindowText, QColor(*SHARED.theme.colText)) + palette.setColor(QPalette.Text, QColor(*SHARED.theme.colText)) - self.setPalette(thePalette) - self.theTitle.setPalette(thePalette) + self.setPalette(palette) + self.itemTitle.setPalette(palette) return @@ -2573,7 +2495,7 @@ def setTitleFromHandle(self, tHandle: str | None) -> bool: """ self._docHandle = tHandle if tHandle is None: - self.theTitle.setText("") + self.itemTitle.setText("") self.editButton.setVisible(False) self.searchButton.setVisible(False) self.closeButton.setVisible(False) @@ -2589,12 +2511,12 @@ def setTitleFromHandle(self, tHandle: str | None) -> bool: if nwItem is not None: tTitle.append(nwItem.itemName) sSep = " %s " % nwUnicode.U_RSAQUO - self.theTitle.setText(sSep.join(tTitle)) + self.itemTitle.setText(sSep.join(tTitle)) else: nwItem = pTree[tHandle] if nwItem is None: return False - self.theTitle.setText(nwItem.itemName) + self.itemTitle.setText(nwItem.itemName) self.editButton.setVisible(True) self.searchButton.setVisible(True) @@ -2673,9 +2595,8 @@ def __init__(self, docEditor: GuiDocEditor) -> None: logger.debug("Create: GuiDocEditFooter") self.docEditor = docEditor - self.mainGui = docEditor.mainGui - self._theItem = None + self._tItem = None self._docHandle = None self._docSelection = False @@ -2781,15 +2702,15 @@ def matchColours(self) -> None: """Update the colours of the widget to match those of the syntax theme rather than the main GUI. """ - thePalette = QPalette() - thePalette.setColor(QPalette.Window, QColor(*SHARED.theme.colBack)) - thePalette.setColor(QPalette.WindowText, QColor(*SHARED.theme.colText)) - thePalette.setColor(QPalette.Text, QColor(*SHARED.theme.colText)) + palette = QPalette() + palette.setColor(QPalette.Window, QColor(*SHARED.theme.colBack)) + palette.setColor(QPalette.WindowText, QColor(*SHARED.theme.colText)) + palette.setColor(QPalette.Text, QColor(*SHARED.theme.colText)) - self.setPalette(thePalette) - self.statusText.setPalette(thePalette) - self.linesText.setPalette(thePalette) - self.wordsText.setPalette(thePalette) + self.setPalette(palette) + self.statusText.setPalette(palette) + self.linesText.setPalette(palette) + self.wordsText.setPalette(palette) return @@ -2798,9 +2719,9 @@ def setHandle(self, tHandle: str | None) -> None: self._docHandle = tHandle if self._docHandle is None: logger.debug("No handle set, so clearing the editor footer") - self._theItem = None + self._tItem = None else: - self._theItem = SHARED.project.tree[self._docHandle] + self._tItem = SHARED.project.tree[self._docHandle] self.setHasSelection(False) self.updateInfo() @@ -2817,13 +2738,13 @@ def setHasSelection(self, hasSelection: bool) -> None: def updateInfo(self) -> None: """Update the content of text labels.""" - if self._theItem is None: + if self._tItem is None: sIcon = QPixmap() sText = "" else: - theStatus, theIcon = self._theItem.getImportStatus(incIcon=True) - sIcon = theIcon.pixmap(self.sPx, self.sPx) - sText = f"{theStatus} / {self._theItem.describeMe()}" + status, icon = self._tItem.getImportStatus(incIcon=True) + sIcon = icon.pixmap(self.sPx, self.sPx) + sText = f"{status} / {self._tItem.describeMe()}" self.statusIcon.setPixmap(sIcon) self.statusText.setText(sText) @@ -2832,13 +2753,13 @@ def updateInfo(self) -> None: def updateLineCount(self) -> None: """Update the line counter.""" - if self._theItem is None: + if self._tItem is None: iLine = 0 iDist = 0 else: - theCursor = self.docEditor.textCursor() - iLine = theCursor.blockNumber() + 1 - iDist = 100*iLine/self.docEditor.document().blockCount() + cursor = self.docEditor.textCursor() + iLine = cursor.blockNumber() + 1 + iDist = 100*iLine/self.docEditor._qDocument.blockCount() self.linesText.setText( self.tr("Line: {0} ({1})").format(f"{iLine:n}", f"{iDist:.0f} %") ) @@ -2858,18 +2779,18 @@ def updateCounts(self, wCount: int | None = None, cCount: int | None = None) -> def _updateWordCounts(self) -> None: """Update the word count for the whole document.""" - if self._theItem is None: + if self._tItem is None: wCount = 0 wDiff = 0 else: - wCount = self._theItem.wordCount - wDiff = wCount - self._theItem.initCount + wCount = self._tItem.wordCount + wDiff = wCount - self._tItem.initCount self.wordsText.setText( self.tr("Words: {0} ({1})").format(f"{wCount:n}", f"{wDiff:+n}") ) - byteSize = self.docEditor.document().characterCount() + byteSize = self.docEditor._qDocument.characterCount() self.wordsText.setToolTip( self.tr("Document size is {0} bytes").format(f"{byteSize:n}") ) diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py index 7cd673649..5acd7ef71 100644 --- a/novelwriter/gui/dochighlight.py +++ b/novelwriter/gui/dochighlight.py @@ -29,7 +29,8 @@ from PyQt5.QtCore import Qt, QRegularExpression from PyQt5.QtGui import ( - QColor, QTextCharFormat, QFont, QSyntaxHighlighter, QBrush, QTextDocument + QBrush, QColor, QFont, QSyntaxHighlighter, QTextBlockUserData, + QTextCharFormat, QTextDocument ) from novelwriter import CONFIG, SHARED @@ -38,6 +39,9 @@ logger = logging.getLogger(__name__) +SPELLRX = QRegularExpression(r"\b[^\s\-\+\/–—]+\b") +SPELLRX.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption) + class GuiDocHighlighter(QSyntaxHighlighter): @@ -51,9 +55,9 @@ def __init__(self, document: QTextDocument) -> None: logger.debug("Create: GuiDocHighlighter") + self._tItem = None self._tHandle = None self._spellCheck = False - self._spellRx = QRegularExpression() self._hRules: list[tuple[str, dict]] = [] self._hStyles: dict[str, QTextCharFormat] = {} @@ -79,11 +83,6 @@ def __init__(self, document: QTextDocument) -> None: return - @property - def spellCheck(self) -> bool: - """Check if spell checking is enabled.""" - return self._spellCheck - def initHighlighter(self) -> None: """Initialise the syntax highlighter, setting all the colour rules and building the RegExes. @@ -227,13 +226,6 @@ def initHighlighter(self) -> None: hReg.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption) self.rxRules.append((hReg, regRules)) - # Build a QRegExp for the spell checker - # Include additional characters that the highlighter should - # consider to be word separators - uCode = nwUnicode.U_ENDASH + nwUnicode.U_EMDASH - self._spellRx = QRegularExpression(r"\b[^\s\-\+\/" + uCode + r"]+\b") - self._spellRx.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption) - return ## @@ -248,6 +240,11 @@ def setSpellCheck(self, state: bool) -> None: def setHandle(self, tHandle: str) -> None: """Set the handle of the currently highlighted document.""" self._tHandle = tHandle + self._tItem = SHARED.project.tree[tHandle] + logger.debug( + "Syntax highlighter %s for item '%s'", + "enabled" if self._tItem else "disabled", tHandle + ) return ## @@ -284,27 +281,24 @@ def highlightBlock(self, text: str) -> None: if text.startswith("@"): # Keywords and commands self.setCurrentBlockState(self.BLOCK_META) - pIndex = SHARED.project.index - tItem = SHARED.project.tree[self._tHandle] - if tItem is None: - return - - isValid, theBits, thePos = pIndex.scanThis(text) - isGood = pIndex.checkThese(theBits, tItem) - if isValid: - for n, theBit in enumerate(theBits): - xPos = thePos[n] - xLen = len(theBit) - if isGood[n]: - if n == 0: - self.setFormat(xPos, xLen, self._hStyles["keyword"]) + if self._tItem: + pIndex = SHARED.project.index + isValid, theBits, thePos = pIndex.scanThis(text) + isGood = pIndex.checkThese(theBits, self._tItem) + if isValid: + for n, theBit in enumerate(theBits): + xPos = thePos[n] + xLen = len(theBit) + if isGood[n]: + if n == 0: + self.setFormat(xPos, xLen, self._hStyles["keyword"]) + else: + self.setFormat(xPos, xLen, self._hStyles["value"]) else: - self.setFormat(xPos, xLen, self._hStyles["value"]) - else: - kwFmt = self.format(xPos) - kwFmt.setUnderlineColor(self._colError) - kwFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) - self.setFormat(xPos, xLen, kwFmt) + kwFmt = self.format(xPos) + kwFmt.setUnderlineColor(self._colError) + kwFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) + self.setFormat(xPos, xLen, kwFmt) # We never want to run the spell checker on keyword/values, # so we force a return here @@ -382,17 +376,13 @@ def highlightBlock(self, text: str) -> None: spFmt.merge(xFmt[xM]) self.setFormat(x, 1, spFmt) - if not self._spellCheck: - return + data = self.currentBlockUserData() + if not isinstance(data, TextBlockData): + data = TextBlockData() + self.setCurrentBlockUserData(data) - rxSpell = self._spellRx.globalMatch(text.replace("_", " "), 0) - while rxSpell.hasNext(): - rxMatch = rxSpell.next() - if not SHARED.spelling.checkWord(rxMatch.captured(0)): - if not rxMatch.captured(0).isalpha() or rxMatch.captured(0).isupper(): - continue - xPos = rxMatch.capturedStart(0) - xLen = rxMatch.capturedLength(0) + if self._spellCheck: + for xPos, xLen in data.spellCheck(text): for x in range(xPos, xPos+xLen): spFmt = self.format(x) spFmt.setUnderlineColor(self._colSpell) @@ -437,3 +427,33 @@ def _makeFormat(self, color: QColor | None = None, style: str | None = None, return charFormat # END Class GuiDocHighlighter + + +class TextBlockData(QTextBlockUserData): + + __slots__ = ("_spellErrors") + + def __init__(self) -> None: + super().__init__() + self._spellErrors: list[tuple[int, int]] = [] + return + + @property + def spellErrors(self) -> list[tuple[int, int]]: + """Return spell error data from last check.""" + return self._spellErrors + + def spellCheck(self, text: str) -> list[tuple[int, int]]: + """Run the spell checker and cache the result, and return the + list of spell check errors. + """ + self._spellErrors = [] + rxSpell = SPELLRX.globalMatch(text.replace("_", " "), 0) + while rxSpell.hasNext(): + rxMatch = rxSpell.next() + if not SHARED.spelling.checkWord(rxMatch.captured(0)): + if not rxMatch.captured(0).isnumeric() and not rxMatch.captured(0).isupper(): + self._spellErrors.append((rxMatch.capturedStart(0), rxMatch.capturedLength(0))) + return self._spellErrors + +# END Class TextBlockData diff --git a/novelwriter/gui/editordocument.py b/novelwriter/gui/editordocument.py new file mode 100644 index 000000000..160e85eda --- /dev/null +++ b/novelwriter/gui/editordocument.py @@ -0,0 +1,126 @@ +""" +novelWriter – GUI Text Document +=============================== + +File History: +Created: 2023-09-07 [2.2b1] + +This file is a part of novelWriter +Copyright 2018–2023, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see