#!/usr/bin/env python # --!-- coding: utf8 --!-- import re from PyQt5.QtCore import QTimer, QModelIndex, Qt, QEvent, pyqtSignal, QRegExp, QLocale from PyQt5.QtGui import QTextBlockFormat, QTextCharFormat, QFont, QColor, QIcon, QMouseEvent, QTextCursor from PyQt5.QtWidgets import QWidget, QTextEdit, qApp, QAction, QMenu from manuskript import settings from manuskript.enums import Outline from manuskript.functions import AUC, themeIcon from manuskript.functions import toString from manuskript.models import outlineModel from manuskript.ui.editors.MDFunctions import MDFormatSelection from manuskript.ui.editors.MMDHighlighter import MMDHighlighter from manuskript.ui.editors.basicHighlighter import basicHighlighter from manuskript.ui.editors.textFormat import textFormat from manuskript.ui import style as S try: import enchant except ImportError: enchant = None class textEditView(QTextEdit): def __init__(self, parent=None, index=None, html=None, spellcheck=True, highlighting=False, dict="", autoResize=False): QTextEdit.__init__(self, parent) self._column = Outline.text.value self._index = None self._indexes = None self._model = None self._placeholderText = self.placeholderText() self._updating = False self._item = None self._highlighting = highlighting self._textFormat = "text" self.setAcceptRichText(False) # When setting up a theme, this becomes true. self._fromTheme = False self.spellcheck = spellcheck self.currentDict = dict if dict else settings.dict self.highlighter = None self.setAutoResize(autoResize) self._defaultBlockFormat = QTextBlockFormat() self._defaultCharFormat = QTextCharFormat() self.highlightWord = "" self.highligtCS = False self.defaultFontPointSize = qApp.font().pointSize() self._dict = None # self.document().contentsChanged.connect(self.submit, AUC) # Submit text changed only after 500ms without modifications self.updateTimer = QTimer() self.updateTimer.setInterval(500) self.updateTimer.setSingleShot(True) self.updateTimer.timeout.connect(self.submit) # self.updateTimer.timeout.connect(lambda: print("Timeout")) self.updateTimer.stop() self.document().contentsChanged.connect(self.updateTimer.start, AUC) # self.document().contentsChanged.connect(lambda: print("Document changed")) # self.document().contentsChanged.connect(lambda: print(self.objectName(), "Contents changed")) self.setEnabled(False) if index: self.setCurrentModelIndex(index) elif html: self.document().setHtml(html) self.setReadOnly(True) # Spellchecking if enchant and self.spellcheck: try: self._dict = enchant.Dict(self.currentDict if self.currentDict else self.getDefaultLocale()) except enchant.errors.DictNotFoundError: self.spellcheck = False else: self.spellcheck = False if self._highlighting and not self.highlighter: self.highlighter = basicHighlighter(self) self.highlighter.setDefaultBlockFormat(self._defaultBlockFormat) def getDefaultLocale(self): default_locale = enchant.get_default_language() if default_locale is None: default_locale = QLocale.system().name() if default_locale is None: default_locale = enchant.list_dicts()[0][0] return default_locale def setModel(self, model): self._model = model try: self._model.dataChanged.connect(self.update, AUC) except TypeError: pass try: self._model.rowsAboutToBeRemoved.connect(self.rowsAboutToBeRemoved, AUC) except TypeError: pass def setColumn(self, col): self._column = col def setHighlighting(self, val): self._highlighting = val def setDefaultBlockFormat(self, bf): self._defaultBlockFormat = bf if self.highlighter: self.highlighter.setDefaultBlockFormat(bf) def setCurrentModelIndex(self, index): self._indexes = None if index.isValid(): self.setEnabled(True) if index.column() != self._column: index = index.sibling(index.row(), self._column) self._index = index self.setPlaceholderText(self._placeholderText) if not self._model: self.setModel(index.model()) self.setupEditorForIndex(self._index) self.loadFontSettings() self.updateText() else: self._index = QModelIndex() self.setPlainText("") self.setEnabled(False) def currentIndex(self): """ Getter function used to normalized views acces with QAbstractItemViews. """ if self._index: return self._index else: return QModelIndex() def getSelection(self): """ Getter function used to normalized views acces with QAbstractItemViews. """ return [self.currentIndex()] def setCurrentModelIndexes(self, indexes): self._index = None self._indexes = [] for i in indexes: if i.isValid(): self.setEnabled(True) if i.column() != self._column: i = i.sibling(i.row(), self._column) self._indexes.append(i) if not self._model: self.setModel(i.model()) self.updateText() def setupEditorForIndex(self, index): # In which model are we editing? if type(index.model()) != outlineModel: self._textFormat = "text" return # what type of text are we editing? if self._column not in [Outline.text.value, Outline.notes.value]: self._textFormat = "text" else: self._textFormat = "md" # Setting highlighter if self._highlighting: item = index.internalPointer() if self._column in [Outline.text.value, Outline.notes.value]: self.highlighter = MMDHighlighter(self) else: self.highlighter = basicHighlighter(self) self.highlighter.setDefaultBlockFormat(self._defaultBlockFormat) def loadFontSettings(self): if self._fromTheme or \ not self._index or \ type(self._index.model()) != outlineModel or \ self._column != Outline.text.value: return opt = settings.textEditor f = QFont() f.fromString(opt["font"]) background = opt["background"] if not opt["backgroundTransparent"] else "transparent" foreground = opt["fontColor"] if not opt["backgroundTransparent"] else S.text # self.setFont(f) self.setStyleSheet("""QTextEdit{{ background: {bg}; color: {foreground}; font-family: {ff}; font-size: {fs}; margin: {mTB}px {mLR}px; {maxWidth} }} """.format( bg=background, foreground=foreground, ff=f.family(), fs="{}pt".format(str(f.pointSize())), mTB = opt["marginsTB"], mLR = opt["marginsLR"], maxWidth = "max-width: {}px;".format(opt["maxWidth"]) if opt["maxWidth"] else "", ) ) # We set the parent background to the editor's background in case # there are margins. We check that the parent class is a QWidget because # if textEditView is used in fullScreenEditor, then we don't want to # set the background. if self.parent().__class__ == QWidget: self.parent().setStyleSheet(""" QWidget#{name}{{ background: {bg}; }}""".format( # We style by name, otherwise all heriting widgets get the same # colored background, for example context menu. name=self.parent().objectName(), bg=background, )) cf = QTextCharFormat() # cf.setFont(f) # cf.setForeground(QColor(opt["fontColor"])) self.setCursorWidth(opt["cursorWidth"]) bf = QTextBlockFormat() bf.setLineHeight(opt["lineSpacing"], bf.ProportionalHeight) bf.setTextIndent(opt["tabWidth"] * 1 if opt["indent"] else 0) bf.setTopMargin(opt["spacingAbove"]) bf.setBottomMargin(opt["spacingBelow"]) bf.setAlignment(Qt.AlignLeft if opt["textAlignment"] == 0 else Qt.AlignCenter if opt["textAlignment"] == 1 else Qt.AlignRight if opt["textAlignment"] == 2 else Qt.AlignJustify) self._defaultCharFormat = cf self._defaultBlockFormat = bf if self.highlighter: self.highlighter.setMisspelledColor(QColor(opt["misspelled"])) self.highlighter.setDefaultCharFormat(self._defaultCharFormat) self.highlighter.setDefaultBlockFormat(self._defaultBlockFormat) def update(self, topLeft, bottomRight): if self._updating: return elif self._index and self._index.isValid(): if topLeft.parent() != self._index.parent(): return # print("Model changed: ({}:{}), ({}:{}/{}), ({}:{}) for {} of {}".format( # topLeft.row(), topLeft.column(), # self._index.row(), self._index.row(), self._column, # bottomRight.row(), bottomRight.column(), # self.objectName(), self.parent().objectName())) if topLeft.row() <= self._index.row() <= bottomRight.row(): if topLeft.column() <= self._column <= bottomRight.column(): self.updateText() elif self._indexes: update = False for i in self._indexes: if topLeft.row() <= i.row() <= bottomRight.row(): update = True if update: self.updateText() def rowsAboutToBeRemoved(self, parent, first, last): if self._index: if self._index.parent() == parent and \ first <= self._index.row() <= last: self._index = None self.setEnabled(False) # FIXME: self._indexes def disconnectDocument(self): try: self.document().contentsChanged.disconnect(self.updateTimer.start) except: pass def reconnectDocument(self): self.document().contentsChanged.connect(self.updateTimer.start, AUC) def updateText(self): if self._updating: return # print("Updating", self.objectName()) self._updating = True if self._index: self.disconnectDocument() if self.toPlainText() != toString(self._model.data(self._index)): # print(" Updating plaintext") self.document().setPlainText(toString(self._model.data(self._index))) self.reconnectDocument() elif self._indexes: self.disconnectDocument() t = [] same = True for i in self._indexes: item = i.internalPointer() t.append(toString(item.data(self._column))) for t2 in t[1:]: if t2 != t[0]: same = False break if same: self.document().setPlainText(t[0]) else: self.document().setPlainText("") if not self._placeholderText: self._placeholderText = self.placeholderText() self.setPlaceholderText(self.tr("Various")) self.reconnectDocument() self._updating = False def submit(self): self.updateTimer.stop() if self._updating: return # print("Submitting", self.objectName()) if self._index and self._index.isValid(): # item = self._index.internalPointer() if self.toPlainText() != self._model.data(self._index): # print(" Submitting plain text") self._updating = True self._model.setData(self._index, self.toPlainText()) self._updating = False elif self._indexes: self._updating = True for i in self._indexes: item = i.internalPointer() if self.toPlainText() != toString(item.data(self._column)): print("Submitting many indexes") self._model.setData(i, self.toPlainText()) self._updating = False def keyPressEvent(self, event): QTextEdit.keyPressEvent(self, event) if event.key() == Qt.Key_Space: self.submit() # ----------------------------------------------------------------------------------------------------- # Resize stuff def resizeEvent(self, e): QTextEdit.resizeEvent(self, e) if self._autoResize: self.sizeChange() def sizeChange(self): docHeight = self.document().size().height() if self.heightMin <= docHeight <= self.heightMax: self.setMinimumHeight(docHeight) def setAutoResize(self, val): self._autoResize = val if self._autoResize: self.document().contentsChanged.connect(self.sizeChange) self.heightMin = 0 self.heightMax = 65000 self.sizeChange() ############################################################################### # SPELLCHECKING ############################################################################### # Based on http://john.nachtimwald.com/2009/08/22/qplaintextedit-with-in-line-spell-check/ def setDict(self, d): self.currentDict = d self._dict = enchant.Dict(d) if self.highlighter: self.highlighter.rehighlight() def toggleSpellcheck(self, v): self.spellcheck = v if enchant and self.spellcheck and not self._dict: if self.currentDict: self._dict = enchant.Dict(self.currentDict) elif enchant.get_default_language() and enchant.dict_exists(enchant.get_default_language()): self._dict = enchant.Dict(enchant.get_default_language()) else: self.spellcheck = False if self.highlighter: self.highlighter.rehighlight() else: self.spellcheck = False def mousePressEvent(self, event): if event.button() == Qt.RightButton: # Rewrite the mouse event to a left button event so the cursor is # moved to the location of the pointer. event = QMouseEvent(QEvent.MouseButtonPress, event.pos(), Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) QTextEdit.mousePressEvent(self, event) class SpellAction(QAction): """A special QAction that returns the text in a signal. Used for spellckech.""" correct = pyqtSignal(str) def __init__(self, *args): QAction.__init__(self, *args) self.triggered.connect(lambda x: self.correct.emit( str(self.text()))) def contextMenuEvent(self, event): # Based on http://john.nachtimwald.com/2009/08/22/qplaintextedit-with-in-line-spell-check/ popup_menu = self.createStandardContextMenu() popup_menu.exec_(event.globalPos()) def createStandardContextMenu(self): popup_menu = QTextEdit.createStandardContextMenu(self) if not self.spellcheck: return popup_menu # Select the word under the cursor. # But only if there is no selection (otherwise it's impossible to select more text to copy/cut) cursor = self.textCursor() if not cursor.hasSelection(): cursor.select(QTextCursor.WordUnderCursor) self.setTextCursor(cursor) # Check if the selected word is misspelled and offer spelling # suggestions if it is. if cursor.hasSelection(): text = str(cursor.selectedText()) valid = self._dict.check(text) selectedWord = cursor.selectedText() if not valid: spell_menu = QMenu(self.tr('Spelling Suggestions'), self) spell_menu.setIcon(themeIcon("spelling")) for word in self._dict.suggest(text): action = self.SpellAction(word, spell_menu) action.correct.connect(self.correctWord) spell_menu.addAction(action) # Only add the spelling suggests to the menu if there are # suggestions. if len(spell_menu.actions()) != 0: popup_menu.insertSeparator(popup_menu.actions()[0]) # Adds: add to dictionary addAction = QAction(self.tr("&Add to dictionary"), popup_menu) addAction.setIcon(QIcon.fromTheme("list-add")) addAction.triggered.connect(self.addWordToDict) addAction.setData(selectedWord) popup_menu.insertAction(popup_menu.actions()[0], addAction) # Adds: suggestions popup_menu.insertMenu(popup_menu.actions()[0], spell_menu) # popup_menu.insertSeparator(popup_menu.actions()[0]) # If word was added to custom dict, give the possibility to remove it elif valid and self._dict.is_added(selectedWord): popup_menu.insertSeparator(popup_menu.actions()[0]) # Adds: remove from dictionary rmAction = QAction(self.tr("&Remove from custom dictionary"), popup_menu) rmAction.setIcon(QIcon.fromTheme("list-remove")) rmAction.triggered.connect(self.rmWordFromDict) rmAction.setData(selectedWord) popup_menu.insertAction(popup_menu.actions()[0], rmAction) return popup_menu def correctWord(self, word): """ Replaces the selected text with word. """ cursor = self.textCursor() cursor.beginEditBlock() cursor.removeSelectedText() cursor.insertText(word) cursor.endEditBlock() def addWordToDict(self): word = self.sender().data() self._dict.add(word) self.highlighter.rehighlight() def rmWordFromDict(self): word = self.sender().data() self._dict.remove(word) self.highlighter.rehighlight() ############################################################################### # FORMATTING ############################################################################### def focusOutEvent(self, event): """Submit changes just before focusing out.""" QTextEdit.focusOutEvent(self, event) self.submit() def focusInEvent(self, event): """Finds textFormatter and attach them to that view.""" QTextEdit.focusInEvent(self, event) p = self.parent() while p.parent(): p = p.parent() if self._index: for tF in p.findChildren(textFormat, QRegExp(".*"), Qt.FindChildrenRecursively): tF.updateFromIndex(self._index) tF.setTextEdit(self) def applyFormat(self, _format): if self._textFormat == "md": if _format == "Bold": MDFormatSelection(self, 0) elif _format == "Italic": MDFormatSelection(self, 1) elif _format == "Code": MDFormatSelection(self, 2) elif _format == "Clear": MDFormatSelection(self)