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,530 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from functools import partial
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from Qt import QtCore, QtGui, QtWidgets
|
|
10
|
+
from tree_sitter import Node, Tree
|
|
11
|
+
|
|
12
|
+
from ..gui_colors import GuiColors, gui_color
|
|
13
|
+
from ..gutters import GutterWidget
|
|
14
|
+
from ..languages import foldable_query, language_spec
|
|
15
|
+
from ..queries import query_byte_range
|
|
16
|
+
from ..utils import add_menu_action, event_pos, make_shortcut_action
|
|
17
|
+
from . import Behavior
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from ..code_editor import CodeEditor
|
|
21
|
+
|
|
22
|
+
FOLD_ALL_SHORTCUT = "Ctrl+Shift+["
|
|
23
|
+
UNFOLD_ALL_SHORTCUT = "Ctrl+Shift+]"
|
|
24
|
+
MANUAL_FOLD_SHORTCUT = "Ctrl+Shift+."
|
|
25
|
+
FOLD_LEVELS = range(10) # Ctrl+0 to Ctrl+9
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(eq=False)
|
|
29
|
+
class FoldableRegion:
|
|
30
|
+
"""A range of lines that can be folded under its first line
|
|
31
|
+
|
|
32
|
+
Regions compare by identity, because their fold state changes
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
start_line: int # 0-indexed
|
|
36
|
+
end_line: int # 0-indexed
|
|
37
|
+
# The folded tree-sitter node, or None for a fold the user made
|
|
38
|
+
node: Node | None = None
|
|
39
|
+
hide_last_line: bool = True
|
|
40
|
+
depth: int = 0 # Nesting depth (0 = top level)
|
|
41
|
+
# Cursors on the first and last lines of a fold the user made. Qt moves
|
|
42
|
+
# them with the text, so the fold follows its lines when lines above change
|
|
43
|
+
anchors: tuple[QtGui.QTextCursor, QtGui.QTextCursor] | None = None
|
|
44
|
+
is_folded: bool = field(default=False, init=False)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_manual(self) -> bool:
|
|
48
|
+
return self.node is None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def hidden_end(self) -> int:
|
|
52
|
+
"""The last line that folding this region hides"""
|
|
53
|
+
return self.end_line if self.hide_last_line else self.end_line - 1
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def num_hidden(self) -> int:
|
|
57
|
+
return self.hidden_end - self.start_line
|
|
58
|
+
|
|
59
|
+
def hides_line(self, line: int) -> bool:
|
|
60
|
+
return self.is_folded and self.start_line < line <= self.hidden_end
|
|
61
|
+
|
|
62
|
+
def follow_anchors(self) -> bool:
|
|
63
|
+
"""Move a manual fold to the lines its anchors are on now
|
|
64
|
+
|
|
65
|
+
Returns whether it still hides a line
|
|
66
|
+
"""
|
|
67
|
+
if self.anchors is not None:
|
|
68
|
+
self.start_line, self.end_line = (c.blockNumber() for c in self.anchors)
|
|
69
|
+
return self.num_hidden > 0
|
|
70
|
+
|
|
71
|
+
def state_key(self) -> tuple[str, int, int] | None:
|
|
72
|
+
"""Identifies the same tree-sitter region across reparses"""
|
|
73
|
+
if self.node is None:
|
|
74
|
+
return None
|
|
75
|
+
return (self.node.type, self.start_line, self.end_line)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class FoldingGutterArea(GutterWidget):
|
|
79
|
+
"""The gutter column that shows fold indicators and toggles them on click
|
|
80
|
+
|
|
81
|
+
Indicators of folds the user made are drawn in the "manual_fold" color,
|
|
82
|
+
and right clicking one offers to remove it
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
regionClicked = QtCore.Signal(FoldableRegion)
|
|
86
|
+
# The region, and the global position to open its menu at
|
|
87
|
+
regionMenuRequested = QtCore.Signal(FoldableRegion, QtCore.QPoint)
|
|
88
|
+
ICON_SIZE = 9
|
|
89
|
+
PADDING = 2
|
|
90
|
+
|
|
91
|
+
def __init__(self, editor: CodeEditor) -> None:
|
|
92
|
+
super().__init__(editor)
|
|
93
|
+
# The outermost region starting on each line
|
|
94
|
+
self.region_starts: dict[int, FoldableRegion] = {}
|
|
95
|
+
self.manual_color = QtGui.QColor()
|
|
96
|
+
|
|
97
|
+
def width_hint(self) -> int:
|
|
98
|
+
return self.ICON_SIZE + 4 * self.PADDING
|
|
99
|
+
|
|
100
|
+
def paintEvent(self, event: QtGui.QPaintEvent) -> None:
|
|
101
|
+
painter = QtGui.QPainter(self)
|
|
102
|
+
painter.fillRect(event.rect(), self.bg_color)
|
|
103
|
+
tree_pen = QtGui.QPen(self.fg_color, 1.5)
|
|
104
|
+
manual_pen = QtGui.QPen(self.manual_color, 1.5)
|
|
105
|
+
painter.setBrush(QtCore.Qt.BrushStyle.NoBrush)
|
|
106
|
+
for block, top, bottom in self.visible_blocks(event.rect().bottom()):
|
|
107
|
+
region = self.region_starts.get(block.blockNumber())
|
|
108
|
+
if region is not None and bottom >= event.rect().top():
|
|
109
|
+
painter.setPen(manual_pen if region.is_manual else tree_pen)
|
|
110
|
+
self._draw_fold_indicator(painter, top, region.is_folded)
|
|
111
|
+
|
|
112
|
+
def _draw_fold_indicator(
|
|
113
|
+
self, painter: QtGui.QPainter, top: float, folded: bool
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Draw a right-pointing triangle when folded, or a down-pointing one"""
|
|
116
|
+
cx = self.width() // 2
|
|
117
|
+
cy = int(top + self.editor.fontMetrics().height() // 2)
|
|
118
|
+
size = self.ICON_SIZE // 2
|
|
119
|
+
half = size // 2
|
|
120
|
+
if folded:
|
|
121
|
+
corners = [(cx - half, cy - size), (cx + half, cy), (cx - half, cy + size)]
|
|
122
|
+
else:
|
|
123
|
+
corners = [(cx - size, cy - half), (cx, cy + half), (cx + size, cy - half)]
|
|
124
|
+
painter.drawPolygon([QtCore.QPoint(x, y) for x, y in corners])
|
|
125
|
+
|
|
126
|
+
def region_at(self, y: float) -> FoldableRegion | None:
|
|
127
|
+
"""The region whose indicator is at a y position in the gutter"""
|
|
128
|
+
for block, top, bottom in self.visible_blocks(y):
|
|
129
|
+
if top <= y < bottom:
|
|
130
|
+
return self.region_starts.get(block.blockNumber())
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:
|
|
134
|
+
if event.button() == QtCore.Qt.MouseButton.LeftButton:
|
|
135
|
+
region = self.region_at(event_pos(event).y())
|
|
136
|
+
if region is not None:
|
|
137
|
+
self.regionClicked.emit(region)
|
|
138
|
+
super().mousePressEvent(event)
|
|
139
|
+
|
|
140
|
+
def contextMenuEvent(self, event: QtGui.QContextMenuEvent) -> None:
|
|
141
|
+
region = self.region_at(event.pos().y())
|
|
142
|
+
if region is not None and region.is_manual:
|
|
143
|
+
self.regionMenuRequested.emit(region, event.globalPos())
|
|
144
|
+
event.accept()
|
|
145
|
+
else:
|
|
146
|
+
event.ignore()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class CodeFolding(Behavior):
|
|
150
|
+
"""Fold the multi-line nodes of the tree-sitter tree, and manual selections
|
|
151
|
+
|
|
152
|
+
The foldable node types come from the editor language's LanguageSpec
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def __init__(self, editor: CodeEditor) -> None:
|
|
156
|
+
super().__init__(editor)
|
|
157
|
+
# Every region, sorted by start line with parents before their children
|
|
158
|
+
self.regions: list[FoldableRegion] = []
|
|
159
|
+
|
|
160
|
+
self.fold_line_bg_color = QtGui.QColor()
|
|
161
|
+
self.fold_ellipsis_bg_color = QtGui.QColor()
|
|
162
|
+
self.fold_ellipsis_fg_color = QtGui.QColor()
|
|
163
|
+
|
|
164
|
+
self.folding_area = FoldingGutterArea(self.editor)
|
|
165
|
+
self.folding_area.regionClicked.connect(self.toggle_fold)
|
|
166
|
+
self.folding_area.regionMenuRequested.connect(self._show_manual_fold_menu)
|
|
167
|
+
self.watch_options(
|
|
168
|
+
{"font": self.folding_area.setFont, "colors": self._set_colors}
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if self.editor.tree_manager.tree is not None:
|
|
172
|
+
self._update_foldable_regions()
|
|
173
|
+
self.connect(self.editor.tree_manager.reparsed, self._update_foldable_regions)
|
|
174
|
+
self.connect(
|
|
175
|
+
self.editor.behaviorPaint,
|
|
176
|
+
self.paint_folded_lines,
|
|
177
|
+
QtCore.Qt.ConnectionType.DirectConnection,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def _set_colors(self, colors: GuiColors) -> None:
|
|
181
|
+
self.folding_area.manual_color = gui_color(colors, "manual_fold")
|
|
182
|
+
self.folding_area.set_gutter_colors(colors)
|
|
183
|
+
self.fold_line_bg_color = gui_color(colors, "bg_dim")
|
|
184
|
+
self.fold_ellipsis_bg_color = gui_color(colors, "hl_dim")
|
|
185
|
+
self.fold_ellipsis_fg_color = gui_color(colors, "fg_dim")
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
# Regions
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def _set_regions(self, regions: list[FoldableRegion]) -> None:
|
|
192
|
+
# The sort is stable, so parents stay before their children
|
|
193
|
+
regions.sort(key=lambda r: r.start_line)
|
|
194
|
+
self.regions = regions
|
|
195
|
+
starts: dict[int, FoldableRegion] = {}
|
|
196
|
+
for region in regions:
|
|
197
|
+
# A fold the user made wins over the tree's region on the same line
|
|
198
|
+
if region.is_manual or region.start_line not in starts:
|
|
199
|
+
starts[region.start_line] = region
|
|
200
|
+
self.folding_area.region_starts = starts
|
|
201
|
+
self.folding_area.update()
|
|
202
|
+
|
|
203
|
+
def _update_foldable_regions(self) -> None:
|
|
204
|
+
"""Rebuild the regions from the tree, keeping folded regions folded"""
|
|
205
|
+
tree = self.editor.tree_manager.tree
|
|
206
|
+
if tree is None:
|
|
207
|
+
self._set_regions([])
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
folded = [r for r in self.regions if r.is_folded]
|
|
211
|
+
# Nested regions of the same type on the same lines share a key, so
|
|
212
|
+
# each folded key folds as many new regions as it had, parents first
|
|
213
|
+
folded_keys = Counter(r.state_key() for r in folded if not r.is_manual)
|
|
214
|
+
regions = self._find_foldable_regions(tree)
|
|
215
|
+
restored = 0
|
|
216
|
+
for region in regions:
|
|
217
|
+
key = region.state_key()
|
|
218
|
+
if folded_keys[key] > 0:
|
|
219
|
+
folded_keys[key] -= 1
|
|
220
|
+
region.is_folded = True
|
|
221
|
+
restored += 1
|
|
222
|
+
manual = [r for r in folded if r.is_manual and r.follow_anchors()]
|
|
223
|
+
self._set_regions(regions + manual)
|
|
224
|
+
|
|
225
|
+
# A folded region that moved or changed can't be matched up, so it unfolds.
|
|
226
|
+
# Its lines may have moved too, so recompute what is hidden from scratch
|
|
227
|
+
if restored + len(manual) < len(folded):
|
|
228
|
+
self._sync_block_visibility()
|
|
229
|
+
elif folded:
|
|
230
|
+
for region in self.regions:
|
|
231
|
+
if region.is_folded:
|
|
232
|
+
self._apply_region(region)
|
|
233
|
+
self._repaint()
|
|
234
|
+
|
|
235
|
+
def _find_foldable_regions(self, tree: Tree) -> list[FoldableRegion]:
|
|
236
|
+
"""Find the foldable regions in the tree
|
|
237
|
+
|
|
238
|
+
This runs on every reparse, so use a query instead of walking
|
|
239
|
+
the whole tree in python
|
|
240
|
+
"""
|
|
241
|
+
query = foldable_query(tree.language)
|
|
242
|
+
if query is None:
|
|
243
|
+
return []
|
|
244
|
+
root = tree.root_node
|
|
245
|
+
captures = query_byte_range(query, root, 0, root.end_byte)
|
|
246
|
+
# Nodes in document order, with parents before their children
|
|
247
|
+
nodes = sorted(
|
|
248
|
+
captures.get("fold", []), key=lambda n: (n.start_byte, -n.end_byte)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
hide_last_types = language_spec(tree.language).fold_hide_last_line
|
|
252
|
+
# The end bytes of the foldable nodes that contain the current node
|
|
253
|
+
# The depth counts single line nodes too
|
|
254
|
+
parent_ends: list[int] = []
|
|
255
|
+
regions = []
|
|
256
|
+
for node in nodes:
|
|
257
|
+
while parent_ends and parent_ends[-1] <= node.start_byte:
|
|
258
|
+
parent_ends.pop()
|
|
259
|
+
depth = len(parent_ends)
|
|
260
|
+
parent_ends.append(node.end_byte)
|
|
261
|
+
|
|
262
|
+
hide_last = node.type in hide_last_types
|
|
263
|
+
region = FoldableRegion(
|
|
264
|
+
node.start_point.row, node.end_point.row, node, hide_last, depth
|
|
265
|
+
)
|
|
266
|
+
# Single line nodes, and two line nodes that keep their last line,
|
|
267
|
+
# have nothing to hide
|
|
268
|
+
if region.num_hidden > 0:
|
|
269
|
+
regions.append(region)
|
|
270
|
+
return regions
|
|
271
|
+
|
|
272
|
+
# ------------------------------------------------------------------
|
|
273
|
+
# Folding
|
|
274
|
+
# ------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
def _hidden_by_other(self, line: int, region: FoldableRegion) -> bool:
|
|
277
|
+
return any(r.hides_line(line) for r in self.regions if r is not region)
|
|
278
|
+
|
|
279
|
+
def _apply_region(self, region: FoldableRegion) -> None:
|
|
280
|
+
"""Show or hide the lines of a region to match its fold state
|
|
281
|
+
|
|
282
|
+
Unfolding keeps the lines of nested folded regions hidden
|
|
283
|
+
"""
|
|
284
|
+
block = self.editor.document().findBlockByNumber(region.start_line + 1)
|
|
285
|
+
while block.isValid() and block.blockNumber() <= region.hidden_end:
|
|
286
|
+
line = block.blockNumber()
|
|
287
|
+
block.setVisible(
|
|
288
|
+
not region.is_folded and not self._hidden_by_other(line, region)
|
|
289
|
+
)
|
|
290
|
+
block = block.next()
|
|
291
|
+
|
|
292
|
+
def _sync_block_visibility(self) -> None:
|
|
293
|
+
"""Show or hide every line to match all the fold states"""
|
|
294
|
+
block = self.editor.document().firstBlock()
|
|
295
|
+
while block.isValid():
|
|
296
|
+
line = block.blockNumber()
|
|
297
|
+
block.setVisible(not any(r.hides_line(line) for r in self.regions))
|
|
298
|
+
block = block.next()
|
|
299
|
+
self._repaint()
|
|
300
|
+
|
|
301
|
+
def _repaint(self) -> None:
|
|
302
|
+
"""Repaint the text and the gutters after lines were shown or hidden"""
|
|
303
|
+
self.editor.viewport().update()
|
|
304
|
+
self.editor.gutters.update()
|
|
305
|
+
|
|
306
|
+
def set_folded(self, region: FoldableRegion, folded: bool) -> None:
|
|
307
|
+
if region.is_folded != folded:
|
|
308
|
+
region.is_folded = folded
|
|
309
|
+
self._apply_region(region)
|
|
310
|
+
self._repaint()
|
|
311
|
+
|
|
312
|
+
def toggle_fold(self, region: FoldableRegion) -> None:
|
|
313
|
+
self.set_folded(region, not region.is_folded)
|
|
314
|
+
|
|
315
|
+
def _set_fold_states(
|
|
316
|
+
self, should_fold: Callable[[FoldableRegion], bool | None]
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Fold, unfold or leave each region, as should_fold says
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
should_fold: Whether to fold a region, or None to leave it as is
|
|
322
|
+
"""
|
|
323
|
+
for region in self.regions:
|
|
324
|
+
fold = should_fold(region)
|
|
325
|
+
if fold is not None and region.is_folded != fold:
|
|
326
|
+
region.is_folded = fold
|
|
327
|
+
self._apply_region(region)
|
|
328
|
+
self._repaint()
|
|
329
|
+
|
|
330
|
+
def fold_to_level(self, min_depth: int) -> None:
|
|
331
|
+
"""Fold the regions at or deeper than a depth, and unfold the rest
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
min_depth: The depth to start folding at. 0 folds everything,
|
|
335
|
+
1 keeps the top level unfolded, etc.
|
|
336
|
+
"""
|
|
337
|
+
self._set_fold_states(lambda r: r.depth >= min_depth)
|
|
338
|
+
|
|
339
|
+
def unfold_to_level(self, max_depth: int) -> None:
|
|
340
|
+
"""Unfold the regions up to and including a depth
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
max_depth: 0 unfolds the top level only, 1 unfolds the top
|
|
344
|
+
and next level, etc.
|
|
345
|
+
"""
|
|
346
|
+
self._set_fold_states(lambda r: False if r.depth <= max_depth else None)
|
|
347
|
+
|
|
348
|
+
def fold_all(self) -> None:
|
|
349
|
+
self._set_fold_states(lambda r: True)
|
|
350
|
+
|
|
351
|
+
def unfold_all(self) -> None:
|
|
352
|
+
self._set_fold_states(lambda r: False)
|
|
353
|
+
|
|
354
|
+
def create_manual_fold(self) -> bool:
|
|
355
|
+
"""Fold the lines of the selection under its first line
|
|
356
|
+
|
|
357
|
+
Returns whether a fold was made. The last line keeps showing, so it needs
|
|
358
|
+
at least 3 selected lines
|
|
359
|
+
"""
|
|
360
|
+
lines = self._manual_fold_lines()
|
|
361
|
+
if lines is None:
|
|
362
|
+
return False
|
|
363
|
+
start_line, end_line = lines
|
|
364
|
+
doc = self.editor.document()
|
|
365
|
+
anchors = (
|
|
366
|
+
QtGui.QTextCursor(doc.findBlockByNumber(start_line)),
|
|
367
|
+
QtGui.QTextCursor(doc.findBlockByNumber(end_line)),
|
|
368
|
+
)
|
|
369
|
+
# Manual folds keep their last line showing, like a closing bracket
|
|
370
|
+
region = FoldableRegion(
|
|
371
|
+
start_line, end_line, hide_last_line=False, anchors=anchors
|
|
372
|
+
)
|
|
373
|
+
self._set_regions([*self.regions, region])
|
|
374
|
+
self.set_folded(region, True)
|
|
375
|
+
return True
|
|
376
|
+
|
|
377
|
+
def remove_manual_fold(self, region: FoldableRegion) -> None:
|
|
378
|
+
"""Unfold a fold the user made, and take away its indicator"""
|
|
379
|
+
if region not in self.regions:
|
|
380
|
+
return
|
|
381
|
+
self.set_folded(region, False)
|
|
382
|
+
self._set_regions([r for r in self.regions if r is not region])
|
|
383
|
+
|
|
384
|
+
def manual_fold_menu(self, region: FoldableRegion) -> QtWidgets.QMenu:
|
|
385
|
+
"""Make the right click menu for a manual fold's gutter indicator"""
|
|
386
|
+
menu = QtWidgets.QMenu(self.editor)
|
|
387
|
+
add_menu_action(menu, "Remove Fold", partial(self.remove_manual_fold, region))
|
|
388
|
+
return menu
|
|
389
|
+
|
|
390
|
+
def _show_manual_fold_menu(
|
|
391
|
+
self, region: FoldableRegion, global_pos: QtCore.QPoint
|
|
392
|
+
) -> None:
|
|
393
|
+
menu = self.manual_fold_menu(region)
|
|
394
|
+
menu.exec(global_pos)
|
|
395
|
+
# A new menu is made for every right click
|
|
396
|
+
menu.deleteLater()
|
|
397
|
+
|
|
398
|
+
def _manual_fold_lines(self) -> tuple[int, int] | None:
|
|
399
|
+
"""The first and last line of the selection, if it can be folded by hand"""
|
|
400
|
+
cursor = self.editor.textCursor()
|
|
401
|
+
doc = self.editor.document()
|
|
402
|
+
start_line = doc.findBlock(cursor.selectionStart()).blockNumber()
|
|
403
|
+
end_line = doc.findBlock(cursor.selectionEnd()).blockNumber()
|
|
404
|
+
if end_line - start_line < 2:
|
|
405
|
+
return None
|
|
406
|
+
return start_line, end_line
|
|
407
|
+
|
|
408
|
+
def region_at_line(self, line: int) -> FoldableRegion | None:
|
|
409
|
+
"""The region to fold or unfold from a line
|
|
410
|
+
|
|
411
|
+
That is a folded region that starts on the line, since its first line
|
|
412
|
+
is the one that shows, or else the innermost region containing the line
|
|
413
|
+
"""
|
|
414
|
+
containing = [r for r in self.regions if r.start_line <= line <= r.end_line]
|
|
415
|
+
for region in containing:
|
|
416
|
+
if region.is_folded and region.start_line == line:
|
|
417
|
+
return region
|
|
418
|
+
return containing[-1] if containing else None
|
|
419
|
+
|
|
420
|
+
# ------------------------------------------------------------------
|
|
421
|
+
# Painting
|
|
422
|
+
# ------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
def paint_folded_lines(
|
|
425
|
+
self, _event: QtGui.QPaintEvent, painter: QtGui.QPainter
|
|
426
|
+
) -> None:
|
|
427
|
+
"""Shade each folded line, and say how many lines it hides"""
|
|
428
|
+
metrics = self.editor.fontMetrics()
|
|
429
|
+
doc = self.editor.document()
|
|
430
|
+
width = self.editor.viewport().width()
|
|
431
|
+
painter.save()
|
|
432
|
+
try:
|
|
433
|
+
painter.setFont(self.editor.font())
|
|
434
|
+
painter.setPen(self.fold_ellipsis_fg_color)
|
|
435
|
+
for region in self.regions:
|
|
436
|
+
if not region.is_folded:
|
|
437
|
+
continue
|
|
438
|
+
block = doc.findBlockByNumber(region.start_line)
|
|
439
|
+
if not block.isValid() or not block.isVisible():
|
|
440
|
+
continue
|
|
441
|
+
geom = self.editor.blockBoundingGeometry(block).translated(
|
|
442
|
+
self.editor.contentOffset()
|
|
443
|
+
)
|
|
444
|
+
top, height = int(geom.top()), int(geom.height())
|
|
445
|
+
painter.fillRect(0, top, width, height, self.fold_line_bg_color)
|
|
446
|
+
|
|
447
|
+
plural = "s" if region.num_hidden > 1 else ""
|
|
448
|
+
text = f"... ({region.num_hidden} hidden line{plural})"
|
|
449
|
+
x = metrics.horizontalAdvance(block.text() + " ")
|
|
450
|
+
box_padding = 3
|
|
451
|
+
painter.fillRect(
|
|
452
|
+
x - box_padding,
|
|
453
|
+
top,
|
|
454
|
+
metrics.horizontalAdvance(text) + box_padding * 2,
|
|
455
|
+
height,
|
|
456
|
+
self.fold_ellipsis_bg_color,
|
|
457
|
+
)
|
|
458
|
+
painter.drawText(x, int(geom.top() + metrics.ascent()), text)
|
|
459
|
+
finally:
|
|
460
|
+
painter.restore()
|
|
461
|
+
|
|
462
|
+
def on_remove(self) -> None:
|
|
463
|
+
for region in self.regions:
|
|
464
|
+
region.is_folded = False
|
|
465
|
+
self._sync_block_visibility()
|
|
466
|
+
self.folding_area.remove()
|
|
467
|
+
|
|
468
|
+
def get_hotkeys(self) -> list[QtGui.QAction]:
|
|
469
|
+
actions = [
|
|
470
|
+
make_shortcut_action(
|
|
471
|
+
self.editor, FOLD_ALL_SHORTCUT, self.fold_all, "Fold All"
|
|
472
|
+
),
|
|
473
|
+
make_shortcut_action(
|
|
474
|
+
self.editor, UNFOLD_ALL_SHORTCUT, self.unfold_all, "Unfold All"
|
|
475
|
+
),
|
|
476
|
+
make_shortcut_action(
|
|
477
|
+
self.editor,
|
|
478
|
+
MANUAL_FOLD_SHORTCUT,
|
|
479
|
+
self.create_manual_fold,
|
|
480
|
+
"Create Manual Fold",
|
|
481
|
+
),
|
|
482
|
+
]
|
|
483
|
+
for i in FOLD_LEVELS:
|
|
484
|
+
# triggered passes a `checked` bool, so take that before the level
|
|
485
|
+
actions.append(
|
|
486
|
+
make_shortcut_action(
|
|
487
|
+
self.editor,
|
|
488
|
+
f"Ctrl+{i}",
|
|
489
|
+
lambda _checked=False, level=i: self.fold_to_level(level),
|
|
490
|
+
f"Fold to Level {i}",
|
|
491
|
+
)
|
|
492
|
+
)
|
|
493
|
+
return actions
|
|
494
|
+
|
|
495
|
+
def add_menu_actions(
|
|
496
|
+
self, menu: QtWidgets.QMenu, pos: QtCore.QPoint | None
|
|
497
|
+
) -> None:
|
|
498
|
+
fold_menu = menu.addMenu("Folding")
|
|
499
|
+
fold_menu.setEnabled(bool(self.regions))
|
|
500
|
+
|
|
501
|
+
# Fold where the menu was opened, or at the text cursor
|
|
502
|
+
if pos is None:
|
|
503
|
+
line = self.editor.textCursor().blockNumber()
|
|
504
|
+
where = "at Cursor"
|
|
505
|
+
else:
|
|
506
|
+
line = self.editor.cursorForPosition(pos).blockNumber()
|
|
507
|
+
where = "Here"
|
|
508
|
+
region = self.region_at_line(line)
|
|
509
|
+
if region is not None:
|
|
510
|
+
verb = "Unfold" if region.is_folded else "Fold"
|
|
511
|
+
add_menu_action(
|
|
512
|
+
fold_menu, f"{verb} {where}", partial(self.toggle_fold, region)
|
|
513
|
+
)
|
|
514
|
+
fold_menu.addSeparator()
|
|
515
|
+
|
|
516
|
+
add_menu_action(fold_menu, "Fold All", self.fold_all, FOLD_ALL_SHORTCUT)
|
|
517
|
+
add_menu_action(fold_menu, "Unfold All", self.unfold_all, UNFOLD_ALL_SHORTCUT)
|
|
518
|
+
level_menu = fold_menu.addMenu("Fold to Level")
|
|
519
|
+
for i in FOLD_LEVELS:
|
|
520
|
+
add_menu_action(
|
|
521
|
+
level_menu, str(i), partial(self.fold_to_level, i), f"Ctrl+{i}"
|
|
522
|
+
)
|
|
523
|
+
# A manual fold works without any regions
|
|
524
|
+
add_menu_action(
|
|
525
|
+
menu,
|
|
526
|
+
"Create Manual Fold",
|
|
527
|
+
self.create_manual_fold,
|
|
528
|
+
MANUAL_FOLD_SHORTCUT,
|
|
529
|
+
self._manual_fold_lines() is not None,
|
|
530
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from ..cursors.line_edits import replace_lines_at_cursors
|
|
6
|
+
from ..languages import language_spec
|
|
7
|
+
from ..utils import add_menu_action, leading_whitespace, make_shortcut_action
|
|
8
|
+
from . import Behavior
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from Qt import QtCore, QtGui, QtWidgets
|
|
12
|
+
|
|
13
|
+
SHORTCUT = "Ctrl+/"
|
|
14
|
+
LABEL = "Toggle Comment"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CommentToggle(Behavior):
|
|
18
|
+
"""Toggle line comments for the current line or selection (Ctrl+/)
|
|
19
|
+
|
|
20
|
+
The comment prefix comes from the editor language's LanguageSpec
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def comment_prefix(self) -> str:
|
|
25
|
+
return language_spec(self.editor.tree_manager.language).comment_prefix
|
|
26
|
+
|
|
27
|
+
def get_hotkeys(self) -> list[QtGui.QAction]:
|
|
28
|
+
return [make_shortcut_action(self.editor, SHORTCUT, self.toggle_comment, LABEL)]
|
|
29
|
+
|
|
30
|
+
def add_menu_actions(
|
|
31
|
+
self, menu: QtWidgets.QMenu, pos: QtCore.QPoint | None
|
|
32
|
+
) -> None:
|
|
33
|
+
add_menu_action(menu, LABEL, self.toggle_comment, SHORTCUT)
|
|
34
|
+
|
|
35
|
+
def toggle_comment(self) -> None:
|
|
36
|
+
prefix = self.comment_prefix
|
|
37
|
+
with self.editor.edit_session():
|
|
38
|
+
replace_lines_at_cursors(
|
|
39
|
+
self.editor.citer, lambda lines: self.toggle_lines(lines, prefix)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def toggle_lines(cls, lines: list[str], prefix: str) -> list[str]:
|
|
44
|
+
"""Uncomment the lines if every non-blank one is commented, otherwise comment them"""
|
|
45
|
+
filled = [line for line in lines if line.strip()]
|
|
46
|
+
if all(cls._line_is_commented(line, prefix) for line in filled):
|
|
47
|
+
return [cls._uncomment_line(line, prefix) for line in lines]
|
|
48
|
+
# Line the comments up at the smallest indent
|
|
49
|
+
min_indent = min((len(leading_whitespace(line)) for line in filled), default=0)
|
|
50
|
+
return [cls._comment_line(line, prefix, min_indent) for line in lines]
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _line_is_commented(line: str, prefix: str) -> bool:
|
|
54
|
+
# Without its trailing space, so "#foo" counts as commented too
|
|
55
|
+
return line.lstrip().startswith(prefix.rstrip())
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _comment_line(line: str, prefix: str, min_indent: int) -> str:
|
|
59
|
+
if not line.strip():
|
|
60
|
+
return line
|
|
61
|
+
return line[:min_indent] + prefix + line[min_indent:]
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _uncomment_line(line: str, prefix: str) -> str:
|
|
65
|
+
indent = len(leading_whitespace(line))
|
|
66
|
+
rest = line[indent:]
|
|
67
|
+
# Also handle the prefix without its trailing space, like "#foo"
|
|
68
|
+
for p in (prefix, prefix.rstrip()):
|
|
69
|
+
if rest.startswith(p):
|
|
70
|
+
return line[:indent] + rest[len(p) :]
|
|
71
|
+
return line
|