# -*- coding: utf-8 -*- """novelWriter GUI Document Editor novelWriter – GUI Document Editor =================================== Class holding the document editor File History: Created: 2018-09-29 [0.0.1] """ import logging import nw from time import time from PyQt5.QtCore import Qt, QTimer, QSizeF from PyQt5.QtWidgets import ( qApp, QTextEdit, QAction, QMenu, QShortcut, QMessageBox ) from PyQt5.QtGui import ( QTextCursor, QTextOption, QIcon, QKeySequence, QFont, QColor, QPalette, QTextDocument, ) from nw.project import NWDoc from nw.gui.tools import GuiDocHighlighter, WordCounter from nw.tools import NWSpellCheck from nw.constants import nwFiles, nwUnicode from nw.enum import nwDocAction, nwAlert logger = logging.getLogger(__name__) class GuiDocEditor(QTextEdit): def __init__(self, theParent, theProject): QTextEdit.__init__(self) logger.debug("Initialising DocEditor ...") # Class Variables self.mainConf = nw.CONFIG self.theProject = theProject self.theParent = theParent self.theTheme = theParent.theTheme self.docChanged = False self.spellCheck = False self.nwDocument = NWDoc(self.theProject, self.theParent) self.theHandle = None # Document Variables self.charCount = 0 self.wordCount = 0 self.paraCount = 0 self.lastEdit = 0 # Typography self.typDQOpen = self.mainConf.fmtDoubleQuotes[0] self.typDQClose = self.mainConf.fmtDoubleQuotes[1] self.typSQOpen = self.mainConf.fmtSingleQuotes[0] self.typSQClose = self.mainConf.fmtSingleQuotes[1] # Core Elements self.qDocument = self.document() self.qDocument.setDocumentMargin(self.mainConf.textMargin) self.qDocument.contentsChange.connect(self._docChange) if self.mainConf.spellTool == "enchant": from nw.tools.spellenchant import NWSpellEnchant self.theDict = NWSpellEnchant() else: self.theDict = NWSpellCheck() self.hLight = GuiDocHighlighter(self.qDocument, self.theParent) self.hLight.setDict(self.theDict) # Context Menu self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self._openContextMenu) # Editor State self.hasSelection = False self.setMinimumWidth(300) self.setAcceptRichText(False) # Custom Shortcuts QShortcut( QKeySequence("Ctrl+."), self, context=Qt.WidgetShortcut, activated=self._openSpellContext ) QShortcut( Qt.Key_Return | Qt.ControlModifier, self, context=Qt.WidgetShortcut, activated=self._followTag ) QShortcut( Qt.Key_Enter | Qt.ControlModifier, self, context=Qt.WidgetShortcut, activated=self._followTag ) # Set Up Word Count Thread and Timer self.wcInterval = self.mainConf.wordCountTimer self.wcTimer = QTimer() self.wcTimer.setInterval(int(self.wcInterval*1000)) self.wcTimer.timeout.connect(self._runCounter) self.wCounter = WordCounter(self) self.wCounter.finished.connect(self._updateCounts) self.initEditor() logger.debug("DocEditor initialisation complete") return def clearEditor(self): self.nwDocument.clearDocument() self.setReadOnly(True) self.clear() self.wcTimer.stop() self.theHandle = None self.charCount = 0 self.wordCount = 0 self.paraCount = 0 self.lastEdit = 0 self.hasSelection = False self.setDocumentChanged(False) self.theParent.noticeBar.hideNote() return True def initEditor(self): """Initialise or re-initialise the editor with the user's settings. This function is both called when the editor is created, and when the user changes the main editor preferences. """ # Reload dictionaries self.setDictionaries() # Set font theFont = QFont() if self.mainConf.textFont is None: # If none is defined, set the default back to config self.mainConf.textFont = self.qDocument.defaultFont().family() theFont.setFamily(self.mainConf.textFont) theFont.setPointSize(self.mainConf.textSize) self.setFont(theFont) docPalette = self.palette() docPalette.setColor(QPalette.Base, QColor(*self.theTheme.colBack)) docPalette.setColor(QPalette.Text, QColor(*self.theTheme.colText)) self.setPalette(docPalette) # Set default text margins self.qDocument.setDocumentMargin(self.mainConf.textMargin) # Also set the document text options for the document text flow theOpt = QTextOption() if self.mainConf.tabWidth is not None: if self.mainConf.verQtValue >= 51000: theOpt.setTabStopDistance(self.mainConf.tabWidth) if self.mainConf.doJustify: theOpt.setAlignment(Qt.AlignJustify) if self.mainConf.showTabsNSpaces: theOpt.setFlags(theOpt.flags() | QTextOption.ShowTabsAndSpaces) if self.mainConf.showLineEndings: theOpt.setFlags(theOpt.flags() | QTextOption.ShowLineAndParagraphSeparators) self.qDocument.setDefaultTextOption(theOpt) # Initialise the syntax highlighter self.hLight.initHighlighter() # If we have a document open, we should reload it in case the # font changed, otherwise we just clear the editor entirely, # which makes it read only. if self.theHandle is not None: # We must save the current handle as clearEditor() sets it # to None tHandle = self.theHandle self.clearEditor() self.loadText(tHandle) self.changeWidth() else: self.clearEditor() return True def loadText(self, tHandle): """Load text from a document into the editor. If we have an io error, we must handle this and clear the editor so that we don't risk overwriting the file if it exists. This can for instance happen of the file contains binary elements or an encoding that novelWriter does not support. If load is successful, or the document is new (empty string) we set up the editor for editing the file. """ theDoc = self.nwDocument.openDocument(tHandle) if theDoc is None: # There was an io error self.clearEditor() return False self.hLight.setHandle(tHandle) self.setPlainText(theDoc) self.setCursorPosition(self.nwDocument.theItem.cursorPos) self.lastEdit = time() self._runCounter() self.wcTimer.start() self.setDocumentChanged(False) self.theHandle = tHandle if self.nwDocument.docEditable: self.setReadOnly(False) else: self.theParent.noticeBar.showNote("This document is read only.") return True def saveText(self): if self.nwDocument.theItem is None: return False docText = self.getText() cursPos = self.getCursorPosition() theItem = self.nwDocument.theItem theItem.setCharCount(self.charCount) theItem.setWordCount(self.wordCount) theItem.setParaCount(self.paraCount) theItem.setCursorPos(cursPos) self.nwDocument.saveDocument(docText) self.setDocumentChanged(False) self.theParent.theIndex.scanText(theItem.itemHandle, docText) return True ## # Setters and Getters ## def setDocumentChanged(self, bValue): self.docChanged = bValue self.theParent.statusBar.setDocumentStatus(self.docChanged) return self.docChanged def getText(self): """Get the text content of the current document. This method uses QTextEdit->toPlainText for Qt versions lower than 5.9, and the QDocument->toRawText for higher version. The latter preserves non-breaking spaces, which the former does not. """ if self.mainConf.verQtValue >= 50900: theText = self.qDocument.toRawText().replace(nwUnicode.U_PARA,"\n") else: theText = self.toPlainText() return theText def setCursorPosition(self, thePosition): if thePosition > 0: theCursor = self.textCursor() theCursor.setPosition(thePosition) self.setTextCursor(theCursor) return True def getCursorPosition(self): theCursor = self.textCursor() return theCursor.position() ## # Spell Checking ## def setDictionaries(self): self.theDict.setLanguage(self.mainConf.spellLanguage, self.theProject.projDict) return True def setSpellCheck(self, theMode): self.spellCheck = theMode self.hLight.setSpellCheck(theMode) self.hLight.rehighlight() return True def updateSpellCheck(self): if self.spellCheck: self.hLight.rehighlight() return True ## # General Class Methods ## def changeWidth(self): """Automatically adjust the margins so the text is centred, but only if Config.textFixedW is set to True. """ if self.mainConf.textFixedW: vBar = self.verticalScrollBar() if vBar.isVisible(): sW = vBar.width() else: sW = 0 tW = self.mainConf.textWidth wW = self.width() tM = int((wW - sW - tW)/2) if tM < self.mainConf.textMargin: tM = self.mainConf.textMargin docFormat = self.qDocument.rootFrame().frameFormat() docFormat.setLeftMargin(tM) docFormat.setRightMargin(tM) self.qDocument.rootFrame().setFrameFormat(docFormat) return def docAction(self, theAction): logger.verbose("Requesting action: %s" % theAction.name) if not self.theParent.hasProject: logger.error("No project open") return False if theAction == nwDocAction.UNDO: self.undo() elif theAction == nwDocAction.REDO: self.redo() elif theAction == nwDocAction.CUT: self.cut() elif theAction == nwDocAction.COPY: self.copy() elif theAction == nwDocAction.PASTE: self.paste() elif theAction == nwDocAction.BOLD: self._wrapSelection("**","**") elif theAction == nwDocAction.ITALIC: self._wrapSelection("_","_") elif theAction == nwDocAction.U_LINE: self._wrapSelection("__","__") elif theAction == nwDocAction.S_QUOTE: self._wrapSelection(self.typSQOpen,self.typSQClose) elif theAction == nwDocAction.D_QUOTE: self._wrapSelection(self.typDQOpen,self.typDQClose) elif theAction == nwDocAction.SEL_ALL: self._makeSelection(QTextCursor.Document) elif theAction == nwDocAction.SEL_PARA: self._makeSelection(QTextCursor.BlockUnderCursor) elif theAction == nwDocAction.FIND: self._beginSearch() elif theAction == nwDocAction.REPLACE: self._beginReplace() elif theAction == nwDocAction.GO_NEXT: self._findNext() elif theAction == nwDocAction.GO_PREV: self._findPrev() elif theAction == nwDocAction.REPL_NEXT: self._replaceNext() else: logger.error("Unknown or unsupported document action %s" % str(theAction)) return False return True def isEmpty(self): return self.qDocument.isEmpty() def revealLocation(self): if self.theHandle is not None: msgBox = QMessageBox() msgBox.information(self, "File Location", ( "File details for the currently open file
" "Handle: {handle:s}
" "Location: {fileLoc:s}" ).format( handle = self.theHandle, fileLoc = str(self.nwDocument.fileLoc) )) return ## # Document Events and Maintenance ## def keyPressEvent(self, keyEvent): """Intercept key press events. We need to intercept key presses briefly to record the state of selection. This is in order to know whether we had a selection prior to triggering the _docChange slot, as we do not want to trigger autoreplace on selections. Autoreplace on selections messes with undo/redo history. We also need to intercept the Shift key modifier for certain key combinations that modifies standard keys like enter and space. However, we don't want to spend a lot of time in this function as it is triggered on every keypress when typing. """ self.hasSelection = self.textCursor().hasSelection() if keyEvent.modifiers() == Qt.ShiftModifier: theKey = keyEvent.key() if theKey == Qt.Key_Return: self._insertHardBreak() elif theKey == Qt.Key_Enter: self._insertHardBreak() elif theKey == Qt.Key_Space: self._insertNonBreakingSpace() else: QTextEdit.keyPressEvent(self, keyEvent) else: QTextEdit.keyPressEvent(self, keyEvent) return def mouseReleaseEvent(self, mEvent): """If the mouse button is released and the control key is pressed, check if we're clicking on a tag, and trigger the follow tag function. """ if qApp.keyboardModifiers() == Qt.ControlModifier: theCursor = self.cursorForPosition(mEvent.pos()) self._followTag(theCursor) QTextEdit.mouseReleaseEvent(self, mEvent) return ## # Internal Functions ## def _followTag(self, theCursor=None): """Activated by Ctrl+Enter. Checks that we're in a block starting with '@'. We then find the word under the cursor and check that it is after the ':'. If all this is fine, we have a tag and can tell the document viewer to try and find and load the file where the tag is defined. """ if theCursor is None: theCursor = self.textCursor() theBlock = theCursor.block() theText = theBlock.text() if len(theText) == 0: return False if theText.startswith("@"): theCursor.select(QTextCursor.WordUnderCursor) theWord = theCursor.selectedText() cPos = theText.find(":") wPos = theCursor.selectionStart() - theBlock.position() if wPos <= cPos: return False logger.verbose("Attempting to follow tag '%s'" % theWord) self.theParent.docViewer.loadFromTag(theWord) return True def _insertHardBreak(self): theCursor = self.textCursor() theCursor.beginEditBlock() theCursor.insertText(" \n") theCursor.endEditBlock() return def _insertNonBreakingSpace(self): theCursor = self.textCursor() theCursor.beginEditBlock() theCursor.insertText(nwUnicode.U_NBSP) theCursor.endEditBlock() return def _openSpellContext(self): self._openContextMenu(self.cursorRect().center()) return def _openContextMenu(self, thePos): if not self.spellCheck: return theCursor = self.cursorForPosition(thePos) theCursor.select(QTextCursor.WordUnderCursor) theWord = theCursor.selectedText() if theWord == "": return if self.theDict.checkWord(theWord): return mnuSuggest = QMenu() mnuHead = QAction("Spelling Suggestion(s)", mnuSuggest) mnuSuggest.addAction(mnuHead) mnuSuggest.addSeparator() theSuggest = self.theDict.suggestWords(theWord) if len(theSuggest) > 0: for aWord in theSuggest: mnuWord = QAction(aWord, mnuSuggest) mnuWord.triggered.connect( lambda thePos, aWord=aWord : self._correctWord(theCursor, aWord) ) mnuSuggest.addAction(mnuWord) mnuSuggest.addSeparator() mnuAdd = QAction("Add Word to Dictionary", mnuSuggest) mnuAdd.triggered.connect(lambda thePos : self._addWord(theCursor)) mnuSuggest.addAction(mnuAdd) else: mnuHead = QAction("No Suggestions", mnuSuggest) mnuSuggest.addAction(mnuHead) mnuSuggest.exec_(self.viewport().mapToGlobal(thePos)) return def _correctWord(self, theCursor, theWord): xPos = theCursor.selectionStart() theCursor.beginEditBlock() theCursor.removeSelectedText() theCursor.insertText(theWord) theCursor.endEditBlock() theCursor.setPosition(xPos) self.setTextCursor(theCursor) return def _addWord(self, theCursor): theWord = theCursor.selectedText().strip() logger.info("Added '%s' to project dictionary" % theWord) self.theDict.addWord(theWord) self.hLight.setDict(self.theDict) self.hLight.rehighlightBlock(theCursor.block()) return def _docChange(self, thePos, charsRemoved, charsAdded): self.lastEdit = time() if not self.docChanged: self.setDocumentChanged(True) if not self.wcTimer.isActive(): self.wcTimer.start() if self.mainConf.doReplace and not self.hasSelection: self._docAutoReplace(self.qDocument.findBlock(thePos)) return def _docAutoReplace(self, theBlock): """Autoreplace text elements based on main configuration. """ if not theBlock.isValid(): return theText = theBlock.text() theCursor = self.textCursor() thePos = theCursor.positionInBlock() theLen = len(theText) if theLen < 1 or thePos-1 > theLen: return theOne = theText[thePos-1:thePos] theTwo = theText[thePos-2:thePos] theThree = theText[thePos-3:thePos] if self.mainConf.doReplaceDQuote and theTwo == " \"": theCursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 1) theCursor.insertText(self.typDQOpen) elif self.mainConf.doReplaceDQuote and theOne == "\"": theCursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 1) if thePos == 1: theCursor.insertText(self.typDQOpen) else: theCursor.insertText(self.typDQClose) elif self.mainConf.doReplaceSQuote and theTwo == " '": theCursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 1) theCursor.insertText(self.typSQOpen) elif self.mainConf.doReplaceSQuote and theOne == "'": theCursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 1) if thePos == 1: theCursor.insertText(self.typSQOpen) else: theCursor.insertText(self.typSQClose) elif self.mainConf.doReplaceDash and theTwo == "--": theCursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 2) theCursor.insertText(nwUnicode.U_ENDASH) elif self.mainConf.doReplaceDash and theTwo == nwUnicode.U_ENDASH+"-": theCursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 2) theCursor.insertText(nwUnicode.U_EMDASH) elif self.mainConf.doReplaceDots and theThree == "...": theCursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 3) theCursor.insertText(nwUnicode.U_HELLIP) return def _runCounter(self): """Decide whether to run the word counter, or stop the timer due to inactivity. """ sinceActive = time()-self.lastEdit if sinceActive > 5*self.wcInterval: logger.debug("Stopping word count timer: no activity last %.1f seconds" % sinceActive) self.wcTimer.stop() elif self.wCounter.isRunning(): logger.verbose("Word counter thread is busy") else: logger.verbose("Starting word counter") self.wCounter.start() return def _updateCounts(self): """Slot for the word counter's finished signal """ logger.verbose("Updating word count") tHandle = self.nwDocument.docHandle self.charCount = self.wCounter.charCount self.wordCount = self.wCounter.wordCount self.paraCount = self.wCounter.paraCount self.theParent.statusBar.setCounts(self.charCount,self.wordCount,self.paraCount) self.theParent.treeView.propagateCount(tHandle, self.wordCount) self.theParent.treeView.projectWordCount() return def _wrapSelection(self, tBefore, tAfter): """Wraps the selected text in whatever is in tBefore and tAfter. If there is no selection, the autoSelect setting decides the action. AutoSelect will select the word under the cursor before wrapping it. If this feature is disabled, nothing is done. """ theCursor = self.textCursor() if self.mainConf.autoSelect and not theCursor.hasSelection(): theCursor.select(QTextCursor.WordUnderCursor) if theCursor.hasSelection(): posS = theCursor.selectionStart() posE = theCursor.selectionEnd() theCursor.clearSelection() theCursor.beginEditBlock() theCursor.setPosition(posE) theCursor.insertText(tAfter) theCursor.setPosition(posS) theCursor.insertText(tBefore) theCursor.endEditBlock() else: logger.warning("No selection made, nothing to do") return def _makeSelection(self, selMode): theCursor = self.textCursor() theCursor.clearSelection() theCursor.select(selMode) self.setTextCursor(theCursor) return def _beginSearch(self): """Sets the selected text as the search text for the search bar. """ theCursor = self.textCursor() if theCursor.hasSelection(): selText = theCursor.selectedText() else: selText = "" self.theParent.searchBar.setSearchText(selText) return def _beginReplace(self): """Opens the replace line of the search bar and sets the replace text. """ self._beginSearch() self.theParent.searchBar.setReplaceText("") return def _findNext(self): """Searches for the next occurrence of the search bar text in the document. Wraps back to the top if not found. """ searchFor = self.theParent.searchBar.getSearchText() wasFound = self.find(searchFor) if not wasFound: theCursor = self.textCursor() theCursor.movePosition(QTextCursor.Start) self.setTextCursor(theCursor) return def _findPrev(self): """Searches for the previous occurrence of the search bar text in the document. Wraps back to the end if not found. """ searchFor = self.theParent.searchBar.getSearchText() wasFound = self.find(searchFor, QTextDocument.FindBackward) if not wasFound: theCursor = self.textCursor() theCursor.movePosition(QTextCursor.End) self.setTextCursor(theCursor) return def _replaceNext(self): """Searches for the next occurrence of the search bar text in the document and replaces it with the replace text. Wraps back to the top if not found. """ theCursor = self.textCursor() searchFor = self.theParent.searchBar.getSearchText() replWith = self.theParent.searchBar.getReplaceText() if theCursor.hasSelection() and theCursor.selectedText() == searchFor: xPos = theCursor.selectionStart() theCursor.beginEditBlock() theCursor.removeSelectedText() theCursor.insertText(replWith) theCursor.endEditBlock() theCursor.setPosition(xPos) self.setTextCursor(theCursor) logger.verbose("Replaced occurrence of '%s' with '%s' on line %d" % ( searchFor, replWith, theCursor.blockNumber() )) if searchFor != "": self._findNext() return # END Class GuiDocEditor