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.
Files changed (49) hide show
  1. QCodeSitter/__init__.py +38 -0
  2. QCodeSitter/behaviors/__init__.py +115 -0
  3. QCodeSitter/behaviors/auto_bracket.py +141 -0
  4. QCodeSitter/behaviors/code_folding.py +530 -0
  5. QCodeSitter/behaviors/comment_toggle.py +71 -0
  6. QCodeSitter/behaviors/highlight_matching_brackets.py +181 -0
  7. QCodeSitter/behaviors/highlight_matching_selection.py +59 -0
  8. QCodeSitter/behaviors/line_numbers.py +49 -0
  9. QCodeSitter/behaviors/multi_cursor_paint.py +131 -0
  10. QCodeSitter/behaviors/overscroll.py +104 -0
  11. QCodeSitter/behaviors/smart_indent.py +191 -0
  12. QCodeSitter/behaviors/syntax_highlighting.py +181 -0
  13. QCodeSitter/behaviors/tab_completion/__init__.py +257 -0
  14. QCodeSitter/behaviors/tab_completion/popup.py +123 -0
  15. QCodeSitter/behaviors/tab_completion/providers.py +66 -0
  16. QCodeSitter/behaviors/tab_completion/types.py +55 -0
  17. QCodeSitter/code_editor.py +540 -0
  18. QCodeSitter/cursors/__init__.py +8 -0
  19. QCodeSitter/cursors/clipboard.py +51 -0
  20. QCodeSitter/cursors/iterator.py +141 -0
  21. QCodeSitter/cursors/line_edits.py +128 -0
  22. QCodeSitter/cursors/multi.py +240 -0
  23. QCodeSitter/cursors/state.py +55 -0
  24. QCodeSitter/cursors/tracked.py +97 -0
  25. QCodeSitter/defaults.py +93 -0
  26. QCodeSitter/editing_keys.py +150 -0
  27. QCodeSitter/editor_options.py +246 -0
  28. QCodeSitter/gui_colors.py +51 -0
  29. QCodeSitter/gutters.py +141 -0
  30. QCodeSitter/integrations/__init__.py +1 -0
  31. QCodeSitter/integrations/preditor_interface.py +390 -0
  32. QCodeSitter/languages/__init__.py +99 -0
  33. QCodeSitter/languages/base.py +60 -0
  34. QCodeSitter/languages/python.py +146 -0
  35. QCodeSitter/languages/python_highlights.py +444 -0
  36. QCodeSitter/py.typed +0 -0
  37. QCodeSitter/queries/__init__.py +73 -0
  38. QCodeSitter/queries/predicates.py +172 -0
  39. QCodeSitter/syntax_specs.py +65 -0
  40. QCodeSitter/themes.py +271 -0
  41. QCodeSitter/tracked_document.py +107 -0
  42. QCodeSitter/tree_manager.py +180 -0
  43. QCodeSitter/utils.py +84 -0
  44. qcodesitter-0.1.0.dist-info/METADATA +255 -0
  45. qcodesitter-0.1.0.dist-info/RECORD +49 -0
  46. qcodesitter-0.1.0.dist-info/WHEEL +5 -0
  47. qcodesitter-0.1.0.dist-info/entry_points.txt +2 -0
  48. qcodesitter-0.1.0.dist-info/licenses/LICENSE.txt +7 -0
  49. 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