QCodeSitter 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- QCodeSitter/__init__.py +38 -0
- QCodeSitter/behaviors/__init__.py +115 -0
- QCodeSitter/behaviors/auto_bracket.py +141 -0
- QCodeSitter/behaviors/code_folding.py +530 -0
- QCodeSitter/behaviors/comment_toggle.py +71 -0
- QCodeSitter/behaviors/highlight_matching_brackets.py +181 -0
- QCodeSitter/behaviors/highlight_matching_selection.py +59 -0
- QCodeSitter/behaviors/line_numbers.py +49 -0
- QCodeSitter/behaviors/multi_cursor_paint.py +131 -0
- QCodeSitter/behaviors/overscroll.py +104 -0
- QCodeSitter/behaviors/smart_indent.py +191 -0
- QCodeSitter/behaviors/syntax_highlighting.py +181 -0
- QCodeSitter/behaviors/tab_completion/__init__.py +257 -0
- QCodeSitter/behaviors/tab_completion/popup.py +123 -0
- QCodeSitter/behaviors/tab_completion/providers.py +66 -0
- QCodeSitter/behaviors/tab_completion/types.py +55 -0
- QCodeSitter/code_editor.py +540 -0
- QCodeSitter/cursors/__init__.py +8 -0
- QCodeSitter/cursors/clipboard.py +51 -0
- QCodeSitter/cursors/iterator.py +141 -0
- QCodeSitter/cursors/line_edits.py +128 -0
- QCodeSitter/cursors/multi.py +240 -0
- QCodeSitter/cursors/state.py +55 -0
- QCodeSitter/cursors/tracked.py +97 -0
- QCodeSitter/defaults.py +93 -0
- QCodeSitter/editing_keys.py +150 -0
- QCodeSitter/editor_options.py +246 -0
- QCodeSitter/gui_colors.py +51 -0
- QCodeSitter/gutters.py +141 -0
- QCodeSitter/integrations/__init__.py +1 -0
- QCodeSitter/integrations/preditor_interface.py +390 -0
- QCodeSitter/languages/__init__.py +99 -0
- QCodeSitter/languages/base.py +60 -0
- QCodeSitter/languages/python.py +146 -0
- QCodeSitter/languages/python_highlights.py +444 -0
- QCodeSitter/py.typed +0 -0
- QCodeSitter/queries/__init__.py +73 -0
- QCodeSitter/queries/predicates.py +172 -0
- QCodeSitter/syntax_specs.py +65 -0
- QCodeSitter/themes.py +271 -0
- QCodeSitter/tracked_document.py +107 -0
- QCodeSitter/tree_manager.py +180 -0
- QCodeSitter/utils.py +84 -0
- qcodesitter-0.1.0.dist-info/METADATA +255 -0
- qcodesitter-0.1.0.dist-info/RECORD +49 -0
- qcodesitter-0.1.0.dist-info/WHEEL +5 -0
- qcodesitter-0.1.0.dist-info/entry_points.txt +2 -0
- qcodesitter-0.1.0.dist-info/licenses/LICENSE.txt +7 -0
- qcodesitter-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Collection
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from Qt import QtGui, QtWidgets
|
|
7
|
+
|
|
8
|
+
from ..gui_colors import GuiColors, gui_color
|
|
9
|
+
from ..languages import language_spec
|
|
10
|
+
from ..tracked_document import len16, utf16_to_index
|
|
11
|
+
from ..utils import MM
|
|
12
|
+
from . import Behavior
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from tree_sitter import Node
|
|
16
|
+
|
|
17
|
+
from ..code_editor import CodeEditor
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HighlightMatchingBrackets(Behavior):
|
|
21
|
+
"""Highlight a bracket or quote next to the cursor, and the one it pairs with
|
|
22
|
+
|
|
23
|
+
The pairs come from the tree-sitter tree, so brackets in strings and
|
|
24
|
+
comments don't match the code around them. The brackets and quotes come
|
|
25
|
+
from the "highlight_bracket_pairs" and "highlight_quote_chars" options
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
SOURCE = "bracket_matching"
|
|
29
|
+
|
|
30
|
+
def __init__(self, editor: CodeEditor) -> None:
|
|
31
|
+
super().__init__(editor)
|
|
32
|
+
self.watch_options(
|
|
33
|
+
{
|
|
34
|
+
"colors": self._set_colors,
|
|
35
|
+
"highlight_bracket_pairs": self._set_bracket_pairs,
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
self.connect(
|
|
39
|
+
self.editor.cursorPositionChanged, self.highlight_matching_brackets
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def _set_colors(self, colors: GuiColors) -> None:
|
|
43
|
+
self._format = QtGui.QTextCharFormat()
|
|
44
|
+
self._format.setBackground(gui_color(colors, "pair_hl"))
|
|
45
|
+
|
|
46
|
+
def _set_bracket_pairs(self, ordered_pairs: Collection[str]) -> None:
|
|
47
|
+
"""Set the brackets from strings of an opening and closing character, ie "()" """
|
|
48
|
+
self.opening_brackets = "".join(p[0] for p in ordered_pairs)
|
|
49
|
+
self.bracket_chars = "".join(ordered_pairs)
|
|
50
|
+
self.bracket_pairs = {p[0]: p[1] for p in ordered_pairs}
|
|
51
|
+
self.bracket_pairs.update({p[1]: p[0] for p in ordered_pairs})
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def _quote_chars(self) -> str:
|
|
55
|
+
return self.options["highlight_quote_chars"]
|
|
56
|
+
|
|
57
|
+
def on_remove(self) -> None:
|
|
58
|
+
self.editor.clear_selections(self.SOURCE)
|
|
59
|
+
|
|
60
|
+
def highlight_matching_brackets(self) -> None:
|
|
61
|
+
self.editor.set_selections(self.SOURCE, self._matching_selections())
|
|
62
|
+
|
|
63
|
+
def _matching_selections(self) -> list[QtWidgets.QTextEdit.ExtraSelection]:
|
|
64
|
+
match_info = self._find_character_to_match()
|
|
65
|
+
if match_info is None:
|
|
66
|
+
return []
|
|
67
|
+
match_char, match_pos = match_info
|
|
68
|
+
node = self.editor.tree_manager.node_at_char(match_pos)
|
|
69
|
+
if node is None:
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
if match_char in self._quote_chars:
|
|
73
|
+
ranges = self._matching_quote_ranges(node, match_pos)
|
|
74
|
+
else:
|
|
75
|
+
ranges = self._matching_bracket_ranges(node, match_char)
|
|
76
|
+
return [self._create_selection(start, end) for start, end in ranges]
|
|
77
|
+
|
|
78
|
+
def _find_character_to_match(self) -> tuple[str, int] | None:
|
|
79
|
+
"""Find the bracket or quote next to the cursor, preferring the one before it
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The character and its position, or None if neither side matches
|
|
83
|
+
"""
|
|
84
|
+
cursor = self.editor.textCursor()
|
|
85
|
+
pos = cursor.position()
|
|
86
|
+
text = cursor.block().text()
|
|
87
|
+
index = utf16_to_index(text, cursor.positionInBlock())
|
|
88
|
+
matchable = self._quote_chars + self.bracket_chars
|
|
89
|
+
if index > 0 and text[index - 1] in matchable:
|
|
90
|
+
char = text[index - 1]
|
|
91
|
+
return char, pos - len16(char)
|
|
92
|
+
if index < len(text) and text[index] in matchable:
|
|
93
|
+
return text[index], pos
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
def _matching_quote_ranges(
|
|
97
|
+
self, node: Node, match_pos: int
|
|
98
|
+
) -> list[tuple[int, int]]:
|
|
99
|
+
"""Get the ranges of the quote at match_pos, and the other end of its string"""
|
|
100
|
+
spec = language_spec(self.editor.tree_manager.language)
|
|
101
|
+
string_node: Node | None = node
|
|
102
|
+
while string_node is not None and string_node.type != spec.string_type:
|
|
103
|
+
string_node = string_node.parent
|
|
104
|
+
if string_node is None:
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
# The delimiter nodes include triple quotes and prefixes like f"
|
|
108
|
+
delimiters = {child.type: child for child in string_node.children}
|
|
109
|
+
start_type, end_type = spec.string_delimiter_types
|
|
110
|
+
start_node = delimiters.get(start_type)
|
|
111
|
+
end_node = delimiters.get(end_type)
|
|
112
|
+
if start_node is None or end_node is None:
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
doc = self.editor.document()
|
|
116
|
+
opening, closing = (
|
|
117
|
+
(doc.byte_to_char(n.start_byte), doc.byte_to_char(n.end_byte))
|
|
118
|
+
for n in (start_node, end_node)
|
|
119
|
+
)
|
|
120
|
+
if opening[0] <= match_pos < opening[1]:
|
|
121
|
+
return [opening, closing]
|
|
122
|
+
if closing[0] <= match_pos < closing[1]:
|
|
123
|
+
return [closing, opening]
|
|
124
|
+
# A quote inside the string, like the apostrophe in "don't"
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
def _matching_bracket_ranges(
|
|
128
|
+
self, node: Node, match_char: str
|
|
129
|
+
) -> list[tuple[int, int]]:
|
|
130
|
+
"""Get the ranges of a bracket node, and its sibling it pairs with
|
|
131
|
+
|
|
132
|
+
The partner is the nearest balanced one, which matters in error nodes
|
|
133
|
+
that hold several unmatched brackets
|
|
134
|
+
"""
|
|
135
|
+
if node.type not in self.bracket_pairs or node.parent is None:
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
partner_type = self.bracket_pairs[match_char]
|
|
139
|
+
siblings = node.parent.children
|
|
140
|
+
index = siblings.index(node)
|
|
141
|
+
# Opening brackets match forwards, and closing brackets match backwards
|
|
142
|
+
if match_char in self.opening_brackets:
|
|
143
|
+
candidates = siblings[index + 1 :]
|
|
144
|
+
else:
|
|
145
|
+
candidates = siblings[index - 1 :: -1] if index else []
|
|
146
|
+
partner = None
|
|
147
|
+
depth = 0
|
|
148
|
+
for sibling in candidates:
|
|
149
|
+
if sibling.type == node.type:
|
|
150
|
+
depth += 1
|
|
151
|
+
elif sibling.type == partner_type:
|
|
152
|
+
if depth == 0:
|
|
153
|
+
partner = sibling
|
|
154
|
+
break
|
|
155
|
+
depth -= 1
|
|
156
|
+
if partner is None:
|
|
157
|
+
return []
|
|
158
|
+
|
|
159
|
+
doc = self.editor.document()
|
|
160
|
+
return [
|
|
161
|
+
(doc.byte_to_char(n.start_byte), doc.byte_to_char(n.end_byte))
|
|
162
|
+
for n in (node, partner)
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
def _create_selection(
|
|
166
|
+
self, start: int, end: int
|
|
167
|
+
) -> QtWidgets.QTextEdit.ExtraSelection:
|
|
168
|
+
"""Create an ExtraSelection for a range of the document
|
|
169
|
+
|
|
170
|
+
The range is clamped to the document, which it can pass
|
|
171
|
+
if the tree hasn't caught up with an edit yet
|
|
172
|
+
"""
|
|
173
|
+
# -1 for the paragraph separator at the end of the document
|
|
174
|
+
doc_length = self.editor.document().characterCount() - 1
|
|
175
|
+
cursor = self.editor.textCursor()
|
|
176
|
+
cursor.setPosition(min(start, doc_length))
|
|
177
|
+
cursor.setPosition(min(end, doc_length), MM.KeepAnchor)
|
|
178
|
+
selection = QtWidgets.QTextEdit.ExtraSelection()
|
|
179
|
+
selection.cursor = cursor
|
|
180
|
+
selection.format = self._format
|
|
181
|
+
return selection
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from Qt import QtGui, QtWidgets
|
|
6
|
+
|
|
7
|
+
from ..gui_colors import GuiColors, gui_color
|
|
8
|
+
from . import Behavior
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ..code_editor import CodeEditor
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HighlightMatchingSelection(Behavior):
|
|
15
|
+
"""Highlight the other occurrences of the selected text"""
|
|
16
|
+
|
|
17
|
+
SOURCE = "selection_highlight"
|
|
18
|
+
MIN_LENGTH = 2
|
|
19
|
+
MAX_LENGTH = 100
|
|
20
|
+
|
|
21
|
+
def __init__(self, editor: CodeEditor) -> None:
|
|
22
|
+
super().__init__(editor)
|
|
23
|
+
self.watch_options({"colors": self._set_colors})
|
|
24
|
+
self.connect(self.editor.selectionChanged, self.highlight_occurrences)
|
|
25
|
+
|
|
26
|
+
def _set_colors(self, colors: GuiColors) -> None:
|
|
27
|
+
self._match_hl = gui_color(colors, "match_hl")
|
|
28
|
+
|
|
29
|
+
def on_remove(self) -> None:
|
|
30
|
+
self.editor.clear_selections(self.SOURCE)
|
|
31
|
+
|
|
32
|
+
def highlight_occurrences(self) -> None:
|
|
33
|
+
"""Highlight all occurrences of the selected text"""
|
|
34
|
+
extra_selections = []
|
|
35
|
+
cursor = self.editor.textCursor()
|
|
36
|
+
selected_text = cursor.selectedText()
|
|
37
|
+
|
|
38
|
+
# Single characters would match everywhere
|
|
39
|
+
if self.MIN_LENGTH <= len(selected_text) <= self.MAX_LENGTH:
|
|
40
|
+
fmt = QtGui.QTextCharFormat()
|
|
41
|
+
fmt.setBackground(self._match_hl)
|
|
42
|
+
doc = self.editor.document()
|
|
43
|
+
search_cursor = QtGui.QTextCursor(doc)
|
|
44
|
+
case_sensitive = QtGui.QTextDocument.FindFlag.FindCaseSensitively
|
|
45
|
+
|
|
46
|
+
while True:
|
|
47
|
+
search_cursor = doc.find(selected_text, search_cursor, case_sensitive)
|
|
48
|
+
if search_cursor.isNull():
|
|
49
|
+
break
|
|
50
|
+
|
|
51
|
+
# Qt already highlights the selection itself
|
|
52
|
+
found = (search_cursor.selectionStart(), search_cursor.selectionEnd())
|
|
53
|
+
if found != (cursor.selectionStart(), cursor.selectionEnd()):
|
|
54
|
+
selection = QtWidgets.QTextEdit.ExtraSelection()
|
|
55
|
+
selection.cursor = search_cursor
|
|
56
|
+
selection.format = fmt
|
|
57
|
+
extra_selections.append(selection)
|
|
58
|
+
|
|
59
|
+
self.editor.set_selections(self.SOURCE, extra_selections)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from Qt import QtCore, QtGui
|
|
6
|
+
|
|
7
|
+
from ..gutters import GutterWidget
|
|
8
|
+
from . import Behavior
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ..code_editor import CodeEditor
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LineNumberArea(GutterWidget):
|
|
15
|
+
def width_hint(self) -> int:
|
|
16
|
+
digits = len(str(max(1, self.editor.blockCount())))
|
|
17
|
+
return 10 + self.fontMetrics().horizontalAdvance("9") * digits
|
|
18
|
+
|
|
19
|
+
def paintEvent(self, event: QtGui.QPaintEvent) -> None:
|
|
20
|
+
painter = QtGui.QPainter(self)
|
|
21
|
+
painter.fillRect(event.rect(), self.bg_color)
|
|
22
|
+
painter.setPen(self.fg_color)
|
|
23
|
+
for block, top, bottom in self.visible_blocks(event.rect().bottom()):
|
|
24
|
+
if bottom >= event.rect().top():
|
|
25
|
+
painter.drawText(
|
|
26
|
+
0,
|
|
27
|
+
int(top),
|
|
28
|
+
self.width() - 5,
|
|
29
|
+
self.fontMetrics().height(),
|
|
30
|
+
QtCore.Qt.AlignmentFlag.AlignRight,
|
|
31
|
+
str(block.blockNumber() + 1),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LineNumbers(Behavior):
|
|
36
|
+
"""A gutter showing the line numbers"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, editor: CodeEditor) -> None:
|
|
39
|
+
super().__init__(editor)
|
|
40
|
+
self.line_number_area = LineNumberArea(self.editor)
|
|
41
|
+
self.watch_options(
|
|
42
|
+
{
|
|
43
|
+
"font": self.line_number_area.setFont,
|
|
44
|
+
"colors": self.line_number_area.set_gutter_colors,
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def on_remove(self) -> None:
|
|
49
|
+
self.line_number_area.remove()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from Qt import QtCore, QtGui, QtWidgets
|
|
6
|
+
|
|
7
|
+
from ..gui_colors import GuiColors, gui_color
|
|
8
|
+
from . import Behavior
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ..code_editor import CodeEditor
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MultiCursorPaint(Behavior):
|
|
15
|
+
"""Draws every cursor, including the secondary cursors of multi-cursor mode
|
|
16
|
+
|
|
17
|
+
Qt's own cursor is hidden while this behavior is installed, and the primary
|
|
18
|
+
cursor is drawn the same way as the others. The selections of the secondary
|
|
19
|
+
cursors are shown as extra selections.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
SOURCE = "multi_cursor"
|
|
23
|
+
|
|
24
|
+
def __init__(self, editor: CodeEditor) -> None:
|
|
25
|
+
super().__init__(editor)
|
|
26
|
+
self.cursor_width = 2
|
|
27
|
+
self.watch_options({"colors": self._set_colors})
|
|
28
|
+
self._qt_cursor_width = self.editor.cursorWidth()
|
|
29
|
+
self.editor.setCursorWidth(0)
|
|
30
|
+
|
|
31
|
+
self._blink_state = True
|
|
32
|
+
self.blink_timer = QtCore.QTimer(self.editor)
|
|
33
|
+
self.blink_timer.timeout.connect(self._update_blink)
|
|
34
|
+
self.blink_timer.start(QtWidgets.QApplication.instance().cursorFlashTime() // 2)
|
|
35
|
+
self.connect(
|
|
36
|
+
self.editor.behaviorPaint,
|
|
37
|
+
self.paint_cursors,
|
|
38
|
+
QtCore.Qt.ConnectionType.DirectConnection,
|
|
39
|
+
)
|
|
40
|
+
self.connect(self.editor.cursors.changed, self.on_cursors_changed)
|
|
41
|
+
|
|
42
|
+
def _set_colors(self, colors: GuiColors) -> None:
|
|
43
|
+
self.primary_cursor_color = gui_color(colors, "primary_cursor")
|
|
44
|
+
self.secondary_cursor_color = gui_color(colors, "secondary_cursor")
|
|
45
|
+
self.secondary_selection_color = gui_color(colors, "secondary_selection")
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def cursors_visible(self) -> bool:
|
|
49
|
+
"""Whether the cursors are drawn right now, which toggles as they blink"""
|
|
50
|
+
return self._blink_state
|
|
51
|
+
|
|
52
|
+
@cursors_visible.setter
|
|
53
|
+
def cursors_visible(self, visible: bool) -> None:
|
|
54
|
+
self._blink_state = visible
|
|
55
|
+
self.editor.viewport().update()
|
|
56
|
+
|
|
57
|
+
def on_cursors_changed(self) -> None:
|
|
58
|
+
if self.editor.cursors.secondary:
|
|
59
|
+
self._update_selections()
|
|
60
|
+
else:
|
|
61
|
+
self.editor.clear_selections(self.SOURCE)
|
|
62
|
+
self.editor.viewport().update()
|
|
63
|
+
|
|
64
|
+
def _update_selections(self) -> None:
|
|
65
|
+
"""Show the selections of every cursor as extra selections"""
|
|
66
|
+
# In multi-cursor mode the primary selection is drawn like the others
|
|
67
|
+
states = [*self.editor.cursors.secondary, self.editor.cursors.primary()]
|
|
68
|
+
|
|
69
|
+
fmt = QtGui.QTextCharFormat()
|
|
70
|
+
fmt.setBackground(self.secondary_selection_color.lighter(150))
|
|
71
|
+
doc = self.editor.document()
|
|
72
|
+
selections = []
|
|
73
|
+
for state in states:
|
|
74
|
+
if state.hasSelection():
|
|
75
|
+
selection = QtWidgets.QTextEdit.ExtraSelection()
|
|
76
|
+
selection.format = fmt
|
|
77
|
+
selection.cursor = state.build_cursor(doc)
|
|
78
|
+
selections.append(selection)
|
|
79
|
+
|
|
80
|
+
self.editor.set_selections(self.SOURCE, selections)
|
|
81
|
+
|
|
82
|
+
def paint_cursors(self, _event: QtGui.QPaintEvent, painter: QtGui.QPainter) -> None:
|
|
83
|
+
"""Paint thin cursor lines for all cursors without selections"""
|
|
84
|
+
if not self._blink_state:
|
|
85
|
+
return
|
|
86
|
+
painter.save()
|
|
87
|
+
try:
|
|
88
|
+
cursor = QtGui.QTextCursor(self.editor.document())
|
|
89
|
+
for state in self.editor.cursors.secondary:
|
|
90
|
+
if not state.hasSelection():
|
|
91
|
+
self._draw_cursor_at_position(
|
|
92
|
+
painter, cursor, state.position, self.secondary_cursor_color
|
|
93
|
+
)
|
|
94
|
+
primary = self.editor.cursors.primary()
|
|
95
|
+
if not primary.hasSelection():
|
|
96
|
+
self._draw_cursor_at_position(
|
|
97
|
+
painter, cursor, primary.position, self.primary_cursor_color
|
|
98
|
+
)
|
|
99
|
+
finally:
|
|
100
|
+
painter.restore()
|
|
101
|
+
|
|
102
|
+
def _update_blink(self) -> None:
|
|
103
|
+
if not self.editor.hasFocus():
|
|
104
|
+
if self._blink_state:
|
|
105
|
+
self.cursors_visible = False
|
|
106
|
+
return
|
|
107
|
+
self.cursors_visible = not self._blink_state
|
|
108
|
+
|
|
109
|
+
def _draw_cursor_at_position(
|
|
110
|
+
self,
|
|
111
|
+
painter: QtGui.QPainter,
|
|
112
|
+
cursor: QtGui.QTextCursor,
|
|
113
|
+
position: int,
|
|
114
|
+
color: QtGui.QColor,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Draw a cursor line at a document position, if it's in the viewport"""
|
|
117
|
+
cursor.setPosition(position)
|
|
118
|
+
cursor_rect = self.editor.cursorRect(cursor)
|
|
119
|
+
if not self.editor.viewport().rect().intersects(cursor_rect):
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
pen = QtGui.QPen(color)
|
|
123
|
+
pen.setWidth(self.cursor_width)
|
|
124
|
+
painter.setPen(pen)
|
|
125
|
+
painter.drawLine(cursor_rect.topLeft(), cursor_rect.bottomLeft())
|
|
126
|
+
|
|
127
|
+
def on_remove(self) -> None:
|
|
128
|
+
self.blink_timer.stop()
|
|
129
|
+
self.blink_timer.deleteLater()
|
|
130
|
+
self.editor.clear_selections(self.SOURCE)
|
|
131
|
+
self.editor.setCursorWidth(self._qt_cursor_width)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from Qt import QtCore, QtGui
|
|
6
|
+
|
|
7
|
+
from ..gui_colors import GuiColors, gui_color
|
|
8
|
+
from . import Behavior
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ..code_editor import CodeEditor
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Overscroll(Behavior):
|
|
15
|
+
"""Behavior that allows scrolling past the end of the document"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, editor: CodeEditor) -> None:
|
|
18
|
+
super().__init__(editor)
|
|
19
|
+
self._brush = QtGui.QBrush(QtCore.Qt.BrushStyle.SolidPattern)
|
|
20
|
+
self._pen = QtGui.QPen(QtCore.Qt.PenStyle.NoPen)
|
|
21
|
+
|
|
22
|
+
# Track the scroll position if the scroll position gets clamped
|
|
23
|
+
# to the "normal" scroll range, so we can restore it afterwards
|
|
24
|
+
self._cur_scroll = 0
|
|
25
|
+
self._was_clamped = False
|
|
26
|
+
|
|
27
|
+
sb = self.editor.verticalScrollBar()
|
|
28
|
+
self.connect(sb.valueChanged, self.track_scroll)
|
|
29
|
+
self.connect(sb.rangeChanged, self.on_range_changed)
|
|
30
|
+
self.connect(self.editor.document().contentsChanged, self.update_extra_scroll)
|
|
31
|
+
|
|
32
|
+
self.update_extra_scroll()
|
|
33
|
+
self.connect(
|
|
34
|
+
self.editor.behaviorPaint,
|
|
35
|
+
self.paint_outside_document,
|
|
36
|
+
QtCore.Qt.ConnectionType.DirectConnection,
|
|
37
|
+
)
|
|
38
|
+
self.watch_options({"colors": self._set_colors})
|
|
39
|
+
|
|
40
|
+
def track_scroll(self, value: int) -> None:
|
|
41
|
+
sb = self.editor.verticalScrollBar()
|
|
42
|
+
self._was_clamped = value == sb.maximum() and self._cur_scroll > sb.maximum()
|
|
43
|
+
self._cur_scroll = value
|
|
44
|
+
|
|
45
|
+
def update_extra_scroll(self) -> None:
|
|
46
|
+
"""Let the last line scroll up to the top of the viewport"""
|
|
47
|
+
sb = self.editor.verticalScrollBar()
|
|
48
|
+
newmax = self.editor.document().blockCount() - 1
|
|
49
|
+
if sb.maximum() != newmax:
|
|
50
|
+
sb.setMaximum(newmax)
|
|
51
|
+
|
|
52
|
+
def on_range_changed(self) -> None:
|
|
53
|
+
"""Put back the extra scroll range, and a scroll position Qt clamped"""
|
|
54
|
+
saved_value = self._cur_scroll
|
|
55
|
+
update = self._was_clamped
|
|
56
|
+
self.update_extra_scroll()
|
|
57
|
+
if update:
|
|
58
|
+
sb = self.editor.verticalScrollBar()
|
|
59
|
+
sb.setValue(saved_value)
|
|
60
|
+
|
|
61
|
+
def _set_colors(self, colors: GuiColors) -> None:
|
|
62
|
+
self._brush.setColor(gui_color(colors, "outside_bg"))
|
|
63
|
+
self.editor.viewport().update()
|
|
64
|
+
|
|
65
|
+
def paint_outside_document(
|
|
66
|
+
self, _event: QtGui.QPaintEvent, painter: QtGui.QPainter
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Shade the space below the last line"""
|
|
69
|
+
doc = self.editor.document()
|
|
70
|
+
block = doc.lastBlock()
|
|
71
|
+
|
|
72
|
+
if not block.isValid():
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
last_rect = self.editor.blockBoundingGeometry(block)
|
|
76
|
+
last_rect.translate(self.editor.contentOffset())
|
|
77
|
+
|
|
78
|
+
viewport_rect = QtCore.QRectF(self.editor.viewport().rect())
|
|
79
|
+
if not viewport_rect.intersects(last_rect):
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
painter.save()
|
|
83
|
+
try:
|
|
84
|
+
painter.setBrush(self._brush)
|
|
85
|
+
painter.setPen(self._pen)
|
|
86
|
+
|
|
87
|
+
oob_rect = QtCore.QRect(
|
|
88
|
+
0,
|
|
89
|
+
int(last_rect.bottom()),
|
|
90
|
+
int(viewport_rect.width()),
|
|
91
|
+
int(viewport_rect.height() - last_rect.bottom()),
|
|
92
|
+
)
|
|
93
|
+
painter.drawRect(oob_rect)
|
|
94
|
+
finally:
|
|
95
|
+
painter.restore()
|
|
96
|
+
|
|
97
|
+
def on_remove(self) -> None:
|
|
98
|
+
"""Set the scroll range back to the line count"""
|
|
99
|
+
sb = self.editor.verticalScrollBar()
|
|
100
|
+
viewport_height = self.editor.viewport().height()
|
|
101
|
+
line_height = self.editor.fontMetrics().lineSpacing()
|
|
102
|
+
visible_line_count = viewport_height // line_height
|
|
103
|
+
total_line_count = self.editor.document().blockCount()
|
|
104
|
+
sb.setMaximum(max(0, total_line_count - visible_line_count))
|