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