File indexing completed on 2024-06-23 04:28:04

0001 """
0002 SPDX-FileCopyrightText: 2017 Eliakin Costa <eliakim170@gmail.com>
0003 
0004 SPDX-License-Identifier: GPL-2.0-or-later
0005 """
0006 from PyQt5.QtCore import Qt, QRect, QSize, QPoint, pyqtSlot
0007 from PyQt5.QtWidgets import QPlainTextEdit, QTextEdit, QLabel
0008 from PyQt5.QtGui import QIcon, QColor, QPainter, QTextFormat, QFont, QTextCursor
0009 from scripter.ui_scripter.editor import linenumberarea, debugarea
0010 
0011 ##################
0012 # Constants
0013 ##################
0014 
0015 INDENT_WIDTH = 4  # size in spaces of indent in editor window.
0016 # ideally make this a setting sometime?
0017 
0018 MODIFIER_COMMENT = Qt.ControlModifier
0019 KEY_COMMENT = Qt.Key_M
0020 CHAR_COMMENT = "#"
0021 
0022 CHAR_SPACE = " "
0023 CHAR_COLON = ":"
0024 CHAR_COMMA = ","
0025 CHAR_CONTINUATION = "\\"
0026 CHAR_OPEN_BRACKET = "("
0027 CHAR_OPEN_SQUARE_BRACKET = "["
0028 CHAR_OPEN_BRACE = "{"
0029 CHAR_EQUALS = "="
0030 
0031 
0032 class CodeEditor(QPlainTextEdit):
0033 
0034     DEBUG_AREA_WIDTH = 20
0035 
0036     def __init__(self, scripter, parent=None):
0037         super(CodeEditor, self).__init__(parent)
0038 
0039         self.setLineWrapMode(self.NoWrap)
0040 
0041         self.scripter = scripter
0042         self.lineNumberArea = linenumberarea.LineNumberArea(self)
0043         self.debugArea = debugarea.DebugArea(self)
0044 
0045         self.blockCountChanged.connect(self.updateMarginsWidth)
0046         self.updateRequest.connect(self.updateLineNumberArea)
0047         self.cursorPositionChanged.connect(self.highlightCurrentLine)
0048 
0049         self.updateMarginsWidth()
0050         self.highlightCurrentLine()
0051         self.font = "Monospace"
0052         self._stepped = False
0053         self.debugArrow = QIcon(':/icons/debug_arrow.svg')
0054         self.setCornerWidget(QLabel(str()))
0055         self._documentChanged = False
0056         self.indent_width = INDENT_WIDTH  # maybe one day connect this to a setting
0057 
0058         self.undoAvailable.connect(self.setDocumentModified)
0059 
0060     def debugAreaWidth(self):
0061         return self.DEBUG_AREA_WIDTH
0062 
0063     def lineNumberAreaWidth(self):
0064         """The lineNumberAreaWidth is the quatity of decimal places in blockCount"""
0065         digits = 1
0066         max_ = max(1, self.blockCount())
0067         while (max_ >= 10):
0068             max_ /= 10
0069             digits += 1
0070 
0071         space = 3 + self.fontMetrics().width('9') * digits + 3
0072 
0073         return space
0074 
0075     def resizeEvent(self, event):
0076         super(CodeEditor, self).resizeEvent(event)
0077 
0078         qRect = self.contentsRect()
0079         self.debugArea.setGeometry(QRect(qRect.left(),
0080                                          qRect.top(),
0081                                          self.debugAreaWidth(),
0082                                          qRect.height()))
0083         scrollBarHeight = 0
0084         if (self.horizontalScrollBar().isVisible()):
0085             scrollBarHeight = self.horizontalScrollBar().height()
0086 
0087         self.lineNumberArea.setGeometry(QRect(qRect.left() + self.debugAreaWidth(),
0088                                               qRect.top(),
0089                                               self.lineNumberAreaWidth(),
0090                                               qRect.height() - scrollBarHeight))
0091 
0092     def updateMarginsWidth(self):
0093         self.setViewportMargins(self.lineNumberAreaWidth() + self.debugAreaWidth(), 0, 0, 0)
0094 
0095     def updateLineNumberArea(self, rect, dy):
0096         """ This slot is invoked when the editors viewport has been scrolled """
0097 
0098         if dy:
0099             self.lineNumberArea.scroll(0, dy)
0100             self.debugArea.scroll(0, dy)
0101         else:
0102             self.lineNumberArea.update(0, rect.y(), self.lineNumberArea.width(), rect.height())
0103 
0104         if rect.contains(self.viewport().rect()):
0105             self.updateMarginsWidth()
0106 
0107     def lineNumberAreaPaintEvent(self, event):
0108         """This method draws the current lineNumberArea for while"""
0109         blockColor = QColor(self.palette().base().color()).darker(120)
0110         if (self.palette().base().color().lightness() < 128):
0111             blockColor = QColor(self.palette().base().color()).lighter(120)
0112         if (self.palette().base().color().lightness() < 1):
0113             blockColor = QColor(43, 43, 43)
0114         painter = QPainter(self.lineNumberArea)
0115         painter.fillRect(event.rect(), blockColor)
0116 
0117         block = self.firstVisibleBlock()
0118         blockNumber = block.blockNumber()
0119         top = int(self.blockBoundingGeometry(block).translated(self.contentOffset()).top())
0120         bottom = top + int(self.blockBoundingRect(block).height())
0121         while block.isValid() and top <= event.rect().bottom():
0122             if block.isVisible() and bottom >= event.rect().top():
0123                 number = str(blockNumber + 1)
0124                 painter.setPen(self.palette().text().color())
0125                 painter.drawText(0, top, self.lineNumberArea.width() - 3, self.fontMetrics().height(),
0126                                  Qt.AlignRight, number)
0127 
0128             block = block.next()
0129             top = bottom
0130             bottom = top + int(self.blockBoundingRect(block).height())
0131             blockNumber += 1
0132 
0133     def debugAreaPaintEvent(self, event):
0134         if self.scripter.debugcontroller.isActive and self.scripter.debugcontroller.currentLine:
0135             lineNumber = self.scripter.debugcontroller.currentLine
0136             block = self.document().findBlockByLineNumber(lineNumber - 1)
0137 
0138             if self._stepped:
0139                 cursor = QTextCursor(block)
0140                 self.setTextCursor(cursor)
0141                 self._stepped = False
0142 
0143             top = int(self.blockBoundingGeometry(block).translated(self.contentOffset()).top())
0144 
0145             painter = QPainter(self.debugArea)
0146             pixmap = self.debugArrow.pixmap(QSize(self.debugAreaWidth() - 3, int(self.blockBoundingRect(block).height())))
0147             painter.drawPixmap(QPoint(0, top), pixmap)
0148 
0149     def highlightCurrentLine(self):
0150         """Highlight current line under cursor"""
0151         currentSelection = QTextEdit.ExtraSelection()
0152 
0153         lineColor = QColor(self.palette().base().color()).darker(120)
0154         if (self.palette().base().color().lightness() < 128):
0155             lineColor = QColor(self.palette().base().color()).lighter(120)
0156         if (self.palette().base().color().lightness() < 1):
0157             lineColor = QColor(43, 43, 43)
0158 
0159         currentSelection.format.setBackground(lineColor)
0160         currentSelection.format.setProperty(QTextFormat.FullWidthSelection, True)
0161         currentSelection.cursor = self.textCursor()
0162         currentSelection.cursor.clearSelection()
0163 
0164         self.setExtraSelections([currentSelection])
0165 
0166     def wheelEvent(self, e):
0167         """When the CTRL is pressed during the wheelEvent, zoomIn and zoomOut
0168            slots are invoked"""
0169         if e.modifiers() == Qt.ControlModifier:
0170             delta = e.angleDelta().y()
0171             if delta < 0:
0172                 self.zoomOut()
0173             elif delta > 0:
0174                 self.zoomIn()
0175         else:
0176             super(CodeEditor, self).wheelEvent(e)
0177 
0178     def keyPressEvent(self, e):
0179         modifiers = e.modifiers()
0180         if (e.key() == Qt.Key_Tab):
0181             self.indent()
0182         elif e.key() == Qt.Key_Backtab:
0183             self.dedent()
0184         elif modifiers == MODIFIER_COMMENT and e.key() == KEY_COMMENT:
0185             self.toggleComment()
0186         elif e.key() == Qt.Key_Return:
0187             super(CodeEditor, self).keyPressEvent(e)
0188             self.autoindent()
0189         else:
0190             super(CodeEditor, self).keyPressEvent(e)
0191 
0192     def isEmptyBlock(self, blockNumber):
0193         """ test whether block with number blockNumber contains any non-whitespace
0194         If only whitespace: return true, else return false"""
0195 
0196         # get block text
0197         cursor = self.textCursor()
0198         cursor.movePosition(QTextCursor.Start)
0199         cursor.movePosition(QTextCursor.NextBlock, n=blockNumber)
0200         cursor.movePosition(QTextCursor.StartOfLine)
0201         cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.KeepAnchor)
0202         text = cursor.selectedText()
0203         if text.strip() == "":
0204             return True
0205         else:
0206             return False
0207 
0208     def indent(self):
0209         # tab key has been pressed. Indent current line or selected block by self.indent_width
0210 
0211         cursor = self.textCursor()
0212         # is there a selection?
0213 
0214         selectionStart = cursor.selectionStart()
0215         selectionEnd = cursor.selectionEnd()
0216 
0217         if selectionStart == selectionEnd and cursor.atBlockEnd():
0218             # ie no selection and don't insert in the middle of text
0219             # something smarter might skip whitespace and add a tab in front of
0220             # the next non whitespace character
0221             cursor.insertText(" " * self.indent_width)
0222             return
0223 
0224         cursor.setPosition(selectionStart)
0225         startBlock = cursor.blockNumber()
0226         cursor.setPosition(selectionEnd)
0227         endBlock = cursor.blockNumber()
0228 
0229         cursor.movePosition(QTextCursor.Start)
0230         cursor.movePosition(QTextCursor.NextBlock, n=startBlock)
0231 
0232         for i in range(0, endBlock - startBlock + 1):
0233             if not self.isEmptyBlock(startBlock + i):  # Don't insert whitespace on empty lines
0234                 cursor.movePosition(QTextCursor.StartOfLine)
0235                 cursor.insertText(" " * self.indent_width)
0236 
0237             cursor.movePosition(QTextCursor.NextBlock)
0238 
0239         # QT maintains separate cursors, so don't need to track or reset user's cursor
0240 
0241     def dedentBlock(self, blockNumber):
0242         # dedent the line at blockNumber
0243         cursor = self.textCursor()
0244         cursor.movePosition(QTextCursor.Start)
0245         cursor.movePosition(QTextCursor.NextBlock, n=blockNumber)
0246 
0247         for _ in range(self.indent_width):
0248             cursor.movePosition(QTextCursor.StartOfLine)
0249             cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor)
0250             if cursor.selectedText() == " ":  # need to test each char
0251                 cursor.removeSelectedText()
0252             else:
0253                 break  # stop deleting!
0254 
0255         return
0256 
0257     def dedent(self):
0258         cursor = self.textCursor()
0259         selectionStart = cursor.selectionStart()
0260         selectionEnd = cursor.selectionEnd()
0261 
0262         cursor.setPosition(selectionStart)
0263         startBlock = cursor.blockNumber()
0264         cursor.setPosition(selectionEnd)
0265         endBlock = cursor.blockNumber()
0266 
0267         if endBlock < startBlock:
0268             startBlock, endBlock = endBlock, startBlock
0269 
0270         for blockNumber in range(startBlock, endBlock + 1):
0271             self.dedentBlock(blockNumber)
0272 
0273     def autoindent(self):
0274         """The return key has just been pressed (and processed by the editor)
0275         now insert leading spaces to reflect an appropriate indent level
0276         against the previous line.
0277         This will depend on the end of the previous line. If it ends:
0278         * with a colon (:) then indent to a new indent level
0279         * with a comma (,) then this is an implied continuation line, probably
0280           in the middle of a function's parameter list
0281           - look for last open bracket on previous line (, [ or {
0282             - if found indent to that level + one character,
0283             - otherwise use previous line whitespace, this is probably a list or
0284               parameter list so line up with other elements
0285         * with a backslash (\) then this is a continuation line, probably
0286           on the RHS of an assignment
0287           - similar rules as for comma, but if there is an = character
0288             use that plus one indent level if that is greater
0289         * if it is an open bracket of some sort treat similarly to comma
0290 
0291 
0292         * anything else - a new line at the same indent level. This will preserve
0293           the indent level of whitespace lines. User can shift-tab to dedent
0294           as necessary
0295         """
0296 
0297         cursor = self.textCursor()
0298         block = cursor.block()
0299         block = block.previous()
0300         text = block.text()
0301         indentLevel = len(text) - len(text.lstrip())  # base indent level
0302 
0303         # get last char
0304         try:
0305             lastChar = text.rstrip()[-1]
0306         except IndexError:
0307             lastChar = None
0308 
0309         # work out indent level
0310         if lastChar == CHAR_COLON:
0311             indentLevel = indentLevel + self.indent_width
0312         elif lastChar == CHAR_COMMA:  # technically these are mutually exclusive so if would work
0313             braceLevels = []
0314             for c in [CHAR_OPEN_BRACE, CHAR_OPEN_BRACKET, CHAR_OPEN_SQUARE_BRACKET]:
0315                 braceLevels.append(text.rfind(c))
0316             bracePosition = max(braceLevels)
0317             if bracePosition > 0:
0318                 indentLevel = bracePosition + 1
0319         elif lastChar == CHAR_CONTINUATION:
0320             braceLevels = []
0321             for c in [CHAR_OPEN_BRACE, CHAR_OPEN_BRACKET, CHAR_OPEN_SQUARE_BRACKET]:
0322                 braceLevels.append(text.rfind(c))
0323             bracePosition = max(braceLevels)
0324             equalPosition = text.rfind(CHAR_EQUALS)
0325             if bracePosition > equalPosition:
0326                 indentLevel = bracePosition + 1
0327             if equalPosition > bracePosition:
0328                 indentLevel = equalPosition + self.indent_width
0329             # otherwise they're the same - ie both -1 so use base indent level
0330         elif lastChar in [CHAR_OPEN_BRACE, CHAR_OPEN_BRACKET, CHAR_OPEN_SQUARE_BRACKET]:
0331             indentLevel = len(text.rstrip())
0332 
0333         # indent
0334         cursor.insertText(CHAR_SPACE * indentLevel)
0335 
0336     def toggleComment(self):
0337         """Toggle lines of selected text to/from either comment or uncomment
0338         selected text is obtained from text cursor
0339         If selected text contains both commented and uncommented text this will
0340         flip the state of each line - which may not be desirable.
0341         """
0342 
0343         cursor = self.textCursor()
0344         selectionStart = cursor.selectionStart()
0345         selectionEnd = cursor.selectionEnd()
0346 
0347         cursor.setPosition(selectionStart)
0348         startBlock = cursor.blockNumber()
0349         cursor.setPosition(selectionEnd)
0350         endBlock = cursor.blockNumber()
0351 
0352         cursor.movePosition(QTextCursor.Start)
0353         cursor.movePosition(QTextCursor.NextBlock, n=startBlock)
0354 
0355         for _ in range(0, endBlock - startBlock + 1):
0356             # Test for empty line (if the line is empty moving the cursor right will overflow
0357             # to next line, throwing the line tracking off)
0358             cursor.movePosition(QTextCursor.StartOfLine)
0359             p1 = cursor.position()
0360             cursor.movePosition(QTextCursor.EndOfLine)
0361             p2 = cursor.position()
0362             if p1 == p2:  # empty line - comment it
0363                 cursor.movePosition(QTextCursor.StartOfLine)
0364                 cursor.insertText(CHAR_COMMENT)
0365                 cursor.movePosition(QTextCursor.NextBlock)
0366                 continue
0367 
0368             cursor.movePosition(QTextCursor.StartOfLine)
0369             cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor)
0370             text = cursor.selectedText()
0371 
0372             if text == CHAR_COMMENT:
0373                 cursor.removeSelectedText()
0374             else:
0375                 cursor.movePosition(QTextCursor.StartOfLine)
0376                 cursor.insertText(CHAR_COMMENT)
0377 
0378             cursor.movePosition(QTextCursor.NextBlock)
0379 
0380     @property
0381     def font(self):
0382         return self._font
0383 
0384     @font.setter
0385     def font(self, font="Monospace"):
0386         self._font = font
0387         self.setFont(QFont(font, self.fontInfo().pointSize()))
0388 
0389     def setFontSize(self, size=10):
0390         self.setFont(QFont(self._font, size))
0391 
0392     def setStepped(self, status):
0393         self._stepped = status
0394 
0395     def repaintDebugArea(self):
0396         self.debugArea.repaint()
0397 
0398     @pyqtSlot(bool)
0399     def setDocumentModified(self, changed=False):
0400         self._documentModified = changed