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