1''' 2Created on 26 Mar 2021 3 4@author: Charles Haley 5Based on classes in calibre.gui2.tweak_book.editor 6 7License: GPLv3 Copyright: 2021, Kovid Goyal <kovid at kovidgoyal.net> 8''' 9 10from qt.core import ( 11 QFont, QPainter, QPalette, QPlainTextEdit, QRect, Qt, QTextEdit, 12 QTextFormat, QTextCursor 13) 14 15from calibre.gui2.tweak_book.editor.text import LineNumbers 16from calibre.gui2.tweak_book.editor.themes import get_theme, theme_color 17 18 19class LineNumberArea(LineNumbers): 20 21 def mouseDoubleClickEvent(self, event): 22 super().mousePressEvent(event) 23 self.parent().line_area_doubleclick_event(event) 24 25 26class CodeEditor(QPlainTextEdit): 27 28 def __init__(self, parent): 29 QPlainTextEdit.__init__(self, parent) 30 31 # Use the default theme from the book editor 32 theme = get_theme(None) 33 self.line_number_palette = pal = QPalette() 34 pal.setColor(QPalette.ColorRole.Base, theme_color(theme, 'LineNr', 'bg')) 35 pal.setColor(QPalette.ColorRole.Text, theme_color(theme, 'LineNr', 'fg')) 36 pal.setColor(QPalette.ColorRole.BrightText, theme_color(theme, 'LineNrC', 'fg')) 37 38 self.line_number_area = LineNumberArea(self) 39 40 self.blockCountChanged.connect(self.update_line_number_area_width) 41 self.updateRequest.connect(self.update_line_number_area) 42 self.cursorPositionChanged.connect(self.highlight_cursor_line) 43 44 self.update_line_number_area_width(0) 45 self.highlight_cursor_line() 46 self.clicked_line_numbers = set() 47 48 def highlight_cursor_line(self): 49 sel = QTextEdit.ExtraSelection() 50 # Don't highlight if no text so that the placeholder text shows 51 if not (self.blockCount() == 1 and len(self.toPlainText().strip()) == 0): 52 sel.format.setBackground(self.palette().alternateBase()) 53 sel.format.setProperty(QTextFormat.Property.FullWidthSelection, True) 54 sel.cursor = self.textCursor() 55 sel.cursor.clearSelection() 56 self.setExtraSelections([sel,]) 57 58 def update_line_number_area_width(self, block_count=0): 59 self.gutter_width = self.line_number_area_width() 60 self.setViewportMargins(self.gutter_width, 0, 0, 0) 61 62 def line_number_area_width(self): 63 # get largest width of digits 64 w = self.fontMetrics() 65 self.number_width = max(map(lambda x:w.width(str(x)), range(10))) 66 digits = 1 67 limit = max(1, self.blockCount()) 68 while limit >= 10: 69 limit /= 10 70 digits += 1 71 return self.number_width * (digits+1) 72 73 def update_line_number_area(self, rect, dy): 74 if dy: 75 self.line_number_area.scroll(0, dy) 76 else: 77 self.line_number_area.update(0, rect.y(), self.line_number_area.width(), rect.height()) 78 if rect.contains(self.viewport().rect()): 79 self.update_line_number_area_width() 80 81 def resizeEvent(self, ev): 82 QPlainTextEdit.resizeEvent(self, ev) 83 cr = self.contentsRect() 84 self.line_number_area.setGeometry(QRect(cr.left(), cr.top(), 85 self.line_number_area_width(), cr.height())) 86 87 def line_area_doubleclick_event(self, event): 88 # remember that the result of the divide will be zero-based 89 line = event.y()//self.fontMetrics().height() + 1 + self.firstVisibleBlock().blockNumber() 90 if line in self.clicked_line_numbers: 91 self.clicked_line_numbers.discard(line) 92 else: 93 self.clicked_line_numbers.add(line) 94 self.update(self.line_number_area.geometry()) 95 96 def number_of_lines(self): 97 return self.blockCount() 98 99 def set_clicked_line_numbers(self, new_set): 100 self.clicked_line_numbers = new_set 101 self.update(self.line_number_area.geometry()) 102 103 def paint_line_numbers(self, ev): 104 painter = QPainter(self.line_number_area) 105 painter.fillRect(ev.rect(), self.line_number_palette.color(QPalette.ColorRole.Base)) 106 107 block = self.firstVisibleBlock() 108 num = block.blockNumber() 109 top = int(self.blockBoundingGeometry(block).translated(self.contentOffset()).top()) 110 bottom = top + int(self.blockBoundingRect(block).height()) 111 current = self.textCursor().block().blockNumber() 112 painter.setPen(self.line_number_palette.color(QPalette.ColorRole.Text)) 113 114 while block.isValid() and top <= ev.rect().bottom(): 115 if block.isVisible() and bottom >= ev.rect().top(): 116 set_bold = False 117 set_italic = False 118 if current == num: 119 set_bold = True 120 if num+1 in self.clicked_line_numbers: 121 set_italic = True 122 painter.save() 123 if set_bold or set_italic: 124 f = QFont(self.font()) 125 if set_bold: 126 f.setBold(set_bold) 127 painter.setPen(self.line_number_palette.color(QPalette.ColorRole.BrightText)) 128 f.setItalic(set_italic) 129 painter.setFont(f) 130 else: 131 painter.setFont(self.font()) 132 painter.drawText(0, top, self.line_number_area.width() - 5, self.fontMetrics().height(), 133 Qt.AlignmentFlag.AlignRight, str(num + 1)) 134 painter.restore() 135 block = block.next() 136 top = bottom 137 bottom = top + int(self.blockBoundingRect(block).height()) 138 num += 1 139 140 def keyPressEvent(self, ev): 141 if ev.key() == Qt.Key.Key_Insert: 142 self.setOverwriteMode(self.overwriteMode() ^ True) 143 ev.accept() 144 return 145 key = ev.key() 146 if key == Qt.Key_Tab or key == Qt.Key_Backtab: 147 ''' 148 Handle indenting usingTab and Shift Tab. This is remarkably 149 difficult because of the way Qt represents the edit buffer. 150 151 Selections represent the start and end as character positions in the 152 buffer. To convert a position into a line number we must get the 153 block number containing that position. You so this by setting a 154 cursor to that position. 155 156 To get the text of a line we must convert the line number (the 157 block number) to a block and then fetch the text from that. 158 159 To change text we must create a cursor that selects all the text on 160 the line. Because cursors use document positions, not block numbers 161 or blocks, we must convert line numbers to blocks then get the 162 position of the first character of the block. We then "extend" the 163 selection to the end by computing the end position: the start + the 164 length of the text on the line. We then uses that cursor to 165 "insert" the new text, which magically replaces the selected text. 166 ''' 167 # Get the position of the start and end of the selection. 168 cursor = self.textCursor() 169 start_position = cursor.selectionStart() 170 end_position = cursor.selectionEnd() 171 172 # Now convert positions into block (line) numbers 173 cursor.setPosition(start_position) 174 start_block = cursor.block().blockNumber() 175 cursor.setPosition(end_position) 176 end_block = cursor.block().blockNumber() 177 178 def select_block(block_number, curs): 179 # Note the side effect: 'curs' is changed to select the line 180 blk = self.document().findBlockByNumber(block_number) 181 txt = blk.text() 182 pos = blk.position() 183 curs.setPosition(pos) 184 curs.setPosition(pos+len(txt), QTextCursor.KeepAnchor) 185 return txt 186 187 # Check if there is a selection. If not then only Shift-Tab is valid 188 if start_position == end_position: 189 if key == Qt.Key_Backtab: 190 txt = select_block(start_block, cursor) 191 if txt.startswith('\t'): 192 # This works because of the side effect in select_block() 193 cursor.insertText(txt[1:]) 194 cursor.setPosition(start_position-1) 195 self.setTextCursor(cursor) 196 ev.accept() 197 else: 198 QPlainTextEdit.keyPressEvent(self, ev) 199 return 200 # There is a selection so both Tab and Shift-Tab do indenting operations 201 for bn in range(start_block, end_block+1): 202 txt = select_block(bn, cursor) 203 if key == Qt.Key_Backtab: 204 if txt.startswith('\t'): 205 cursor.insertText(txt[1:]) 206 if bn == start_block: 207 start_position -= 1 208 end_position -= 1 209 else: 210 cursor.insertText('\t' + txt) 211 if bn == start_block: 212 start_position += 1 213 end_position += 1 214 # Restore the selection, adjusted for the added or deleted tabs 215 cursor.setPosition(start_position) 216 cursor.setPosition(end_position, QTextCursor.KeepAnchor) 217 self.setTextCursor(cursor) 218 ev.accept() 219 return 220 QPlainTextEdit.keyPressEvent(self, ev) 221 222