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,540 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from collections.abc import Callable, Collection, Iterator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from typing import TypeVar
|
|
7
|
+
|
|
8
|
+
from Qt import QtCore, QtGui, QtWidgets
|
|
9
|
+
from tree_sitter import Language
|
|
10
|
+
|
|
11
|
+
from .behaviors import Behavior
|
|
12
|
+
from .cursors.clipboard import (
|
|
13
|
+
encode_multi_copy,
|
|
14
|
+
indent_tabs_to_spaces,
|
|
15
|
+
split_multi_copy,
|
|
16
|
+
)
|
|
17
|
+
from .cursors.iterator import CursorIterator
|
|
18
|
+
from .cursors.line_edits import edit_joined_spans
|
|
19
|
+
from .cursors.multi import MultiCursor
|
|
20
|
+
from .cursors.state import CursorState
|
|
21
|
+
from .cursors.tracked import TrackedCursor
|
|
22
|
+
from .editing_keys import EditingKeys
|
|
23
|
+
from .editor_options import EditorOptions, OptionHandlers, check_option_keys
|
|
24
|
+
from .gui_colors import GuiColors, gui_color
|
|
25
|
+
from .gutters import GutterLayout
|
|
26
|
+
from .tracked_document import TrackedDocument
|
|
27
|
+
from .tree_manager import TreeManager
|
|
28
|
+
from .utils import (
|
|
29
|
+
MM,
|
|
30
|
+
MO,
|
|
31
|
+
MOD,
|
|
32
|
+
add_menu_action,
|
|
33
|
+
event_pos,
|
|
34
|
+
make_shortcut_action,
|
|
35
|
+
tidy_separators,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
T_Behavior = TypeVar("T_Behavior", bound=Behavior)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CodeEditor(QtWidgets.QPlainTextEdit):
|
|
42
|
+
"""A multi-cursor plain text editor that keeps a tree-sitter parse tree
|
|
43
|
+
|
|
44
|
+
Features beyond plain editing are Behaviors added with `add_behavior`.
|
|
45
|
+
add_behavior, remove_behavior and get_behavior all match a type and its
|
|
46
|
+
subclasses
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# Emitted with (QPaintEvent, QPainter) to let behaviors paint over the viewport
|
|
50
|
+
behaviorPaint = QtCore.Signal(object, object)
|
|
51
|
+
# Emitted after an undo or redo, once the tree has been reparsed. Behaviors
|
|
52
|
+
# that follow the tree can use TreeManager.reparsed instead
|
|
53
|
+
undoRedoFinished = QtCore.Signal()
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self, options: EditorOptions, parent: QtWidgets.QWidget | None = None
|
|
57
|
+
) -> None:
|
|
58
|
+
super().__init__(parent=parent)
|
|
59
|
+
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
|
|
60
|
+
self._doc = TrackedDocument()
|
|
61
|
+
super().setDocument(self._doc)
|
|
62
|
+
self._selections: dict[str, list[QtWidgets.QTextEdit.ExtraSelection]] = {}
|
|
63
|
+
self._behaviors: list[Behavior] = []
|
|
64
|
+
self._keypress_behaviors: list[Behavior] = []
|
|
65
|
+
self.options = options
|
|
66
|
+
self.cursors = MultiCursor(self)
|
|
67
|
+
self.citer = CursorIterator(self)
|
|
68
|
+
self.gutters = GutterLayout(self)
|
|
69
|
+
|
|
70
|
+
self._loaded = False
|
|
71
|
+
|
|
72
|
+
self.tree_manager = TreeManager(self._doc)
|
|
73
|
+
self._doc.byteContentsChange.connect(self.tree_manager.apply_edit)
|
|
74
|
+
|
|
75
|
+
# The indent and clipboard options are read from self.options when used
|
|
76
|
+
self._option_handlers: OptionHandlers = {
|
|
77
|
+
"font": self._set_font,
|
|
78
|
+
"language": self._set_language,
|
|
79
|
+
"colors": self._set_colors,
|
|
80
|
+
"tab_indent_width": lambda _width: self._update_tab_stop(),
|
|
81
|
+
}
|
|
82
|
+
check_option_keys(self._option_handlers)
|
|
83
|
+
self.options.apply(self._option_handlers, self._option_handlers)
|
|
84
|
+
self.options.optionsUpdated.connect(self._on_options_updated)
|
|
85
|
+
|
|
86
|
+
self.keys = EditingKeys(self)
|
|
87
|
+
for label, shortcut, slot, _ in self._multi_cursor_commands():
|
|
88
|
+
self.addAction(make_shortcut_action(self, shortcut, slot, label))
|
|
89
|
+
|
|
90
|
+
def textCursor(self) -> TrackedCursor:
|
|
91
|
+
return TrackedCursor(self._doc, super().textCursor())
|
|
92
|
+
|
|
93
|
+
def document(self) -> TrackedDocument:
|
|
94
|
+
return self._doc
|
|
95
|
+
|
|
96
|
+
def setDocument(self, document: QtGui.QTextDocument) -> None:
|
|
97
|
+
"""Not supported. The tree, cursors and behaviors are bound to the editor's
|
|
98
|
+
own TrackedDocument
|
|
99
|
+
"""
|
|
100
|
+
raise NotImplementedError("CodeEditor can't change its TrackedDocument")
|
|
101
|
+
|
|
102
|
+
@contextmanager
|
|
103
|
+
def edit_session(self) -> Iterator[None]:
|
|
104
|
+
"""Group the edits made by one user action
|
|
105
|
+
|
|
106
|
+
The cursors are snapshot once for all the citer passes, and the tree
|
|
107
|
+
is only reparsed once at the end. Nested sessions join the outer one
|
|
108
|
+
"""
|
|
109
|
+
if self.citer.active:
|
|
110
|
+
yield
|
|
111
|
+
return
|
|
112
|
+
self.tree_manager.pause()
|
|
113
|
+
try:
|
|
114
|
+
with self.citer.session():
|
|
115
|
+
yield
|
|
116
|
+
finally:
|
|
117
|
+
self.tree_manager.unpause()
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# Options
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def load(self) -> None:
|
|
124
|
+
"""Set the language, which parses and highlights the document
|
|
125
|
+
|
|
126
|
+
Those are the expensive part of making an editor, so they wait until
|
|
127
|
+
the editor is first shown. Call this to load an editor that isn't shown
|
|
128
|
+
"""
|
|
129
|
+
if not self._loaded:
|
|
130
|
+
self._loaded = True
|
|
131
|
+
self.options.apply(self._option_handlers, ["language"])
|
|
132
|
+
|
|
133
|
+
def _on_options_updated(self, keylist: Collection[str]) -> None:
|
|
134
|
+
self.options.apply(self._option_handlers, keylist)
|
|
135
|
+
|
|
136
|
+
def _set_language(self, language: Language) -> None:
|
|
137
|
+
if self._loaded:
|
|
138
|
+
self.tree_manager.set_language(language)
|
|
139
|
+
|
|
140
|
+
def _set_font(self, font: QtGui.QFont) -> None:
|
|
141
|
+
self.setFont(font)
|
|
142
|
+
self._update_tab_stop()
|
|
143
|
+
|
|
144
|
+
def _update_tab_stop(self) -> None:
|
|
145
|
+
metrics = QtGui.QFontMetrics(self.font())
|
|
146
|
+
width = self.options["tab_indent_width"]
|
|
147
|
+
self.setTabStopDistance(width * metrics.horizontalAdvance(" "))
|
|
148
|
+
|
|
149
|
+
def _set_colors(self, colors: GuiColors) -> None:
|
|
150
|
+
palette = self.palette()
|
|
151
|
+
role = QtGui.QPalette.ColorRole
|
|
152
|
+
for roles, name in (
|
|
153
|
+
((role.Base, role.Window), "bg"),
|
|
154
|
+
((role.Text,), "fg"),
|
|
155
|
+
((role.Highlight,), "selection"),
|
|
156
|
+
):
|
|
157
|
+
for color_role in roles:
|
|
158
|
+
palette.setColor(color_role, gui_color(colors, name))
|
|
159
|
+
self.setPalette(palette)
|
|
160
|
+
self.setAutoFillBackground(True)
|
|
161
|
+
|
|
162
|
+
def showEvent(self, event: QtGui.QShowEvent) -> None:
|
|
163
|
+
super().showEvent(event)
|
|
164
|
+
self.load()
|
|
165
|
+
|
|
166
|
+
def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
|
|
167
|
+
super().resizeEvent(event)
|
|
168
|
+
self.gutters.place()
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
# Extra selections
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def set_selections(
|
|
175
|
+
self, source: str, selections: list[QtWidgets.QTextEdit.ExtraSelection]
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Set the extra selections from one source, like a behavior
|
|
178
|
+
|
|
179
|
+
Every source's selections are shown together, ordered by source name
|
|
180
|
+
"""
|
|
181
|
+
self._selections[source] = list(selections)
|
|
182
|
+
self._update_editor_selections()
|
|
183
|
+
|
|
184
|
+
def clear_selections(self, source: str) -> None:
|
|
185
|
+
if source in self._selections:
|
|
186
|
+
del self._selections[source]
|
|
187
|
+
self._update_editor_selections()
|
|
188
|
+
|
|
189
|
+
def selections(self, source: str) -> list[QtWidgets.QTextEdit.ExtraSelection]:
|
|
190
|
+
"""Get a copy of the extra selections set by a source"""
|
|
191
|
+
return list(self._selections.get(source, []))
|
|
192
|
+
|
|
193
|
+
def _update_editor_selections(self) -> None:
|
|
194
|
+
merged = []
|
|
195
|
+
for source in sorted(self._selections):
|
|
196
|
+
merged.extend(self._selections[source])
|
|
197
|
+
self.setExtraSelections(merged)
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
# Behaviors
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def add_behavior(self, behavior_cls: type[T_Behavior]) -> T_Behavior:
|
|
204
|
+
"""Add a behavior, replacing any installed behavior of that type or of a
|
|
205
|
+
subclass of it
|
|
206
|
+
|
|
207
|
+
Adding a subclass doesn't replace its base class; call
|
|
208
|
+
remove_behavior(Base) first to swap one in
|
|
209
|
+
"""
|
|
210
|
+
self.remove_behavior(behavior_cls)
|
|
211
|
+
behavior = behavior_cls(self)
|
|
212
|
+
self._behaviors.append(behavior)
|
|
213
|
+
if behavior.handles_key_presses():
|
|
214
|
+
self._keypress_behaviors.append(behavior)
|
|
215
|
+
# The sort is stable, so the same priorities keep the order they were added
|
|
216
|
+
self._keypress_behaviors.sort(key=lambda bh: -bh.KEY_PRIORITY)
|
|
217
|
+
behavior.shortcut_actions = behavior.get_hotkeys()
|
|
218
|
+
self.addActions(behavior.shortcut_actions)
|
|
219
|
+
return behavior
|
|
220
|
+
|
|
221
|
+
def remove_behavior(self, behavior_cls: type[T_Behavior]) -> T_Behavior | None:
|
|
222
|
+
"""Remove the behaviors of a type or of its subclasses, and return the
|
|
223
|
+
first one if there was one
|
|
224
|
+
"""
|
|
225
|
+
removed = [bh for bh in self._behaviors if isinstance(bh, behavior_cls)]
|
|
226
|
+
if not removed:
|
|
227
|
+
return None
|
|
228
|
+
if len(removed) > 1:
|
|
229
|
+
warnings.warn(
|
|
230
|
+
f"Found {len(removed)} {behavior_cls.__name__} behaviors to remove",
|
|
231
|
+
stacklevel=2,
|
|
232
|
+
)
|
|
233
|
+
self._behaviors = [b for b in self._behaviors if b not in removed]
|
|
234
|
+
self._keypress_behaviors = [
|
|
235
|
+
b for b in self._keypress_behaviors if b not in removed
|
|
236
|
+
]
|
|
237
|
+
for bh in removed:
|
|
238
|
+
for action in bh.shortcut_actions:
|
|
239
|
+
self.removeAction(action)
|
|
240
|
+
action.deleteLater()
|
|
241
|
+
bh.remove()
|
|
242
|
+
return removed[0]
|
|
243
|
+
|
|
244
|
+
def get_behavior(self, behavior_cls: type[T_Behavior]) -> T_Behavior | None:
|
|
245
|
+
"""Get the behavior of a type, or of a subclass of it"""
|
|
246
|
+
for bh in self._behaviors:
|
|
247
|
+
if isinstance(bh, behavior_cls):
|
|
248
|
+
return bh
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
# ------------------------------------------------------------------
|
|
252
|
+
# Events
|
|
253
|
+
# ------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:
|
|
256
|
+
if event.modifiers() & MOD.AltModifier:
|
|
257
|
+
self.cursors.add_at(self.cursorForPosition(event_pos(event)).position())
|
|
258
|
+
event.accept()
|
|
259
|
+
return
|
|
260
|
+
self.cursors.clear_secondary()
|
|
261
|
+
super().mousePressEvent(event)
|
|
262
|
+
|
|
263
|
+
def paintEvent(self, event: QtGui.QPaintEvent) -> None:
|
|
264
|
+
super().paintEvent(event)
|
|
265
|
+
painter = QtGui.QPainter(self.viewport())
|
|
266
|
+
try:
|
|
267
|
+
self.behaviorPaint.emit(event, painter)
|
|
268
|
+
finally:
|
|
269
|
+
painter.end()
|
|
270
|
+
|
|
271
|
+
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
|
|
272
|
+
with self.edit_session():
|
|
273
|
+
for behavior in self._keypress_behaviors:
|
|
274
|
+
if behavior.handle_key_press(event):
|
|
275
|
+
self.keys.end_undo_step()
|
|
276
|
+
return
|
|
277
|
+
if not self.keys.handle_key_press(event):
|
|
278
|
+
# Let Qt handle the keys we don't, like Ctrl+A
|
|
279
|
+
with self._qt_edits():
|
|
280
|
+
super().keyPressEvent(event)
|
|
281
|
+
|
|
282
|
+
def inputMethodEvent(self, event: QtGui.QInputMethodEvent) -> None:
|
|
283
|
+
with self._qt_edits():
|
|
284
|
+
super().inputMethodEvent(event)
|
|
285
|
+
|
|
286
|
+
@contextmanager
|
|
287
|
+
def _qt_edits(self) -> Iterator[None]:
|
|
288
|
+
"""Rebuild the tree if Qt edits the document itself
|
|
289
|
+
|
|
290
|
+
Qt's own edits don't go through a TrackedCursor, so tree-sitter isn't told
|
|
291
|
+
about them, and they don't update the secondary cursors, which are dropped.
|
|
292
|
+
Every edit changes the revision, even with undo off. The resync also
|
|
293
|
+
runs if the block raises, so an edit made before the error isn't lost
|
|
294
|
+
"""
|
|
295
|
+
revision = self._doc.revision()
|
|
296
|
+
try:
|
|
297
|
+
yield
|
|
298
|
+
finally:
|
|
299
|
+
if self._doc.revision() != revision:
|
|
300
|
+
self.cursors.clear_secondary()
|
|
301
|
+
self.tree_manager.parse_from_scratch()
|
|
302
|
+
|
|
303
|
+
def contextMenuEvent(self, event: QtGui.QContextMenuEvent) -> None:
|
|
304
|
+
menu = self.create_context_menu(event.pos())
|
|
305
|
+
menu.exec(event.globalPos())
|
|
306
|
+
# A new menu is made for every right click
|
|
307
|
+
menu.deleteLater()
|
|
308
|
+
|
|
309
|
+
def _multi_cursor_commands(
|
|
310
|
+
self,
|
|
311
|
+
) -> tuple[tuple[str, str, Callable[[], object], bool], ...]:
|
|
312
|
+
"""The (label, shortcut, slot, needs a selection) of each multi-cursor
|
|
313
|
+
command, for both the hotkeys and the right click menu
|
|
314
|
+
"""
|
|
315
|
+
cursors = self.cursors
|
|
316
|
+
return (
|
|
317
|
+
("Add Next Occurrence", "Ctrl+D", cursors.add_next_occurrence, False),
|
|
318
|
+
("Add Cursor Above", "Ctrl+Alt+Up", cursors.add_above, False),
|
|
319
|
+
("Add Cursor Below", "Ctrl+Alt+Down", cursors.add_below, False),
|
|
320
|
+
("Cursors to Line Ends", "Ctrl+Shift+L", cursors.add_to_line_ends, True),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def create_context_menu(self, pos: QtCore.QPoint | None = None) -> QtWidgets.QMenu:
|
|
324
|
+
"""Make the right click menu
|
|
325
|
+
|
|
326
|
+
Its actions call this editor's methods. Qt's standard menu calls the
|
|
327
|
+
C++ slots instead, which only edit the primary cursor, and don't
|
|
328
|
+
update the tree
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
pos: Where the menu was opened, in viewport coordinates, or None to
|
|
332
|
+
act on the text cursor
|
|
333
|
+
"""
|
|
334
|
+
menu = QtWidgets.QMenu(self)
|
|
335
|
+
self.populate_edit_menu(menu, pos)
|
|
336
|
+
return menu
|
|
337
|
+
|
|
338
|
+
def populate_edit_menu(
|
|
339
|
+
self, menu: QtWidgets.QMenu, pos: QtCore.QPoint | None = None
|
|
340
|
+
) -> None:
|
|
341
|
+
"""Add the editing commands and then each behavior's actions to a menu
|
|
342
|
+
|
|
343
|
+
This builds the right click menu, and menus outside the editor like a
|
|
344
|
+
window's menu bar. The actions only show their shortcuts, without binding
|
|
345
|
+
them, so the menu never steals the editor's keys. Build the menu just
|
|
346
|
+
before it's shown, since which actions are enabled depends on the editor's
|
|
347
|
+
state, like whether there's a selection
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
menu: The menu to add to
|
|
351
|
+
pos: Where a right click opened the menu, in viewport coordinates.
|
|
352
|
+
None for a menu that wasn't opened at a point in the text, so
|
|
353
|
+
the actions use the text cursor instead
|
|
354
|
+
"""
|
|
355
|
+
doc = self.document()
|
|
356
|
+
selected = self.textCursor().hasSelection()
|
|
357
|
+
sections = (
|
|
358
|
+
(
|
|
359
|
+
("&Undo", self.undo, "Ctrl+Z", doc.isUndoAvailable()),
|
|
360
|
+
("&Redo", self.redo, "Ctrl+Y", doc.isRedoAvailable()),
|
|
361
|
+
),
|
|
362
|
+
(
|
|
363
|
+
("Cu&t", self.cut, "Ctrl+X", selected),
|
|
364
|
+
("&Copy", self.copy, "Ctrl+C", selected),
|
|
365
|
+
("&Paste", self.paste, "Ctrl+V", self.canPaste()),
|
|
366
|
+
(
|
|
367
|
+
"Delete",
|
|
368
|
+
lambda: self.delete(MO.NextCharacter),
|
|
369
|
+
"Del",
|
|
370
|
+
selected,
|
|
371
|
+
),
|
|
372
|
+
),
|
|
373
|
+
(("Select All", self.selectAll, "Ctrl+A", not doc.isEmpty()),),
|
|
374
|
+
)
|
|
375
|
+
for i, section in enumerate(sections):
|
|
376
|
+
if i:
|
|
377
|
+
menu.addSeparator()
|
|
378
|
+
for text, slot, shortcut, enabled in section:
|
|
379
|
+
add_menu_action(menu, text, slot, shortcut, enabled)
|
|
380
|
+
|
|
381
|
+
menu.addSeparator()
|
|
382
|
+
cursor_menu = menu.addMenu("Multi-Cursor")
|
|
383
|
+
for label, shortcut, command, needs_selection in self._multi_cursor_commands():
|
|
384
|
+
enabled = selected or not needs_selection
|
|
385
|
+
add_menu_action(cursor_menu, label, command, shortcut, enabled)
|
|
386
|
+
add_menu_action(
|
|
387
|
+
cursor_menu,
|
|
388
|
+
"Clear Extra Cursors",
|
|
389
|
+
self.cursors.clear_secondary,
|
|
390
|
+
"Esc",
|
|
391
|
+
bool(self.cursors.secondary),
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
for behavior in self._behaviors:
|
|
395
|
+
menu.addSeparator()
|
|
396
|
+
behavior.add_menu_actions(menu, pos)
|
|
397
|
+
tidy_separators(menu)
|
|
398
|
+
|
|
399
|
+
def dropEvent(self, event: QtGui.QDropEvent) -> None:
|
|
400
|
+
"""Drop text through a TrackedCursor, so the tree sees the edit"""
|
|
401
|
+
# insertFromMimeData only inserts text, which multi-copy data also has
|
|
402
|
+
if not event.mimeData().text():
|
|
403
|
+
event.ignore()
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
main_cursor = self.textCursor()
|
|
407
|
+
is_move = (
|
|
408
|
+
event.source() == self
|
|
409
|
+
and event.dropAction() == QtCore.Qt.DropAction.MoveAction
|
|
410
|
+
and main_cursor.hasSelection()
|
|
411
|
+
)
|
|
412
|
+
target_pos = self.cursorForPosition(event_pos(event)).position()
|
|
413
|
+
|
|
414
|
+
sel_start = main_cursor.selectionStart()
|
|
415
|
+
sel_end = main_cursor.selectionEnd()
|
|
416
|
+
if is_move and sel_start <= target_pos <= sel_end:
|
|
417
|
+
event.ignore()
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
with self.edit_session():
|
|
421
|
+
if is_move:
|
|
422
|
+
main_cursor.removeSelectedText()
|
|
423
|
+
if target_pos > sel_start:
|
|
424
|
+
target_pos -= sel_end - sel_start
|
|
425
|
+
|
|
426
|
+
# The drop location becomes the only cursor
|
|
427
|
+
self.cursors.replace_all([CursorState(target_pos, target_pos)])
|
|
428
|
+
self.insertFromMimeData(event.mimeData())
|
|
429
|
+
event.accept()
|
|
430
|
+
|
|
431
|
+
# ------------------------------------------------------------------
|
|
432
|
+
# Editing
|
|
433
|
+
# ------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
def setPlainText(self, text: str) -> None:
|
|
436
|
+
with self._qt_edits():
|
|
437
|
+
super().setPlainText(text)
|
|
438
|
+
|
|
439
|
+
def insertPlainText(self, text: str) -> None:
|
|
440
|
+
"""Type text at every cursor, like `insert_text`"""
|
|
441
|
+
self.insert_text(text)
|
|
442
|
+
|
|
443
|
+
def appendPlainText(self, text: str) -> None:
|
|
444
|
+
"""Add a line of text to the end of the document"""
|
|
445
|
+
with self._qt_edits():
|
|
446
|
+
super().appendPlainText(text)
|
|
447
|
+
|
|
448
|
+
def appendHtml(self, html: str) -> None:
|
|
449
|
+
"""Add a paragraph of html to the end of the document"""
|
|
450
|
+
with self._qt_edits():
|
|
451
|
+
super().appendHtml(html)
|
|
452
|
+
|
|
453
|
+
def clear(self) -> None:
|
|
454
|
+
self.setPlainText("")
|
|
455
|
+
|
|
456
|
+
def undo(self) -> None:
|
|
457
|
+
self._undo_redo(super().undo)
|
|
458
|
+
|
|
459
|
+
def redo(self) -> None:
|
|
460
|
+
self._undo_redo(super().redo)
|
|
461
|
+
|
|
462
|
+
def _undo_redo(self, action: Callable[[], None]) -> None:
|
|
463
|
+
# Qt's undo stack doesn't know about the secondary cursors
|
|
464
|
+
with self._qt_edits():
|
|
465
|
+
action()
|
|
466
|
+
self.undoRedoFinished.emit()
|
|
467
|
+
|
|
468
|
+
# The public editing methods each make one undo step and one reparse
|
|
469
|
+
|
|
470
|
+
def insert_text(self, text: str, join_edit: bool = False) -> None:
|
|
471
|
+
"""Type text at every cursor, replacing their selections"""
|
|
472
|
+
with self.edit_session():
|
|
473
|
+
for cursor in self.citer.iterate_cursors(
|
|
474
|
+
join_edit=join_edit, complete_all=True
|
|
475
|
+
):
|
|
476
|
+
cursor.insertText(text)
|
|
477
|
+
|
|
478
|
+
def delete(self, movement: MO) -> None:
|
|
479
|
+
"""Delete each cursor's selection, or the text it would move over
|
|
480
|
+
|
|
481
|
+
Spans that overlap are deleted together, so a cursor whose span another
|
|
482
|
+
cursor deleted doesn't go on to delete more
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
movement: Like MO.PreviousCharacter for Backspace, or MO.NextWord
|
|
486
|
+
for Ctrl+Delete
|
|
487
|
+
"""
|
|
488
|
+
|
|
489
|
+
def span(cursor: QtGui.QTextCursor) -> tuple[int, int]:
|
|
490
|
+
if not cursor.hasSelection():
|
|
491
|
+
cursor.movePosition(movement, MM.KeepAnchor)
|
|
492
|
+
return cursor.selectionStart(), cursor.selectionEnd()
|
|
493
|
+
|
|
494
|
+
def delete_join(
|
|
495
|
+
cursor: QtGui.QTextCursor, joined: tuple[int, int], shift: int
|
|
496
|
+
) -> None:
|
|
497
|
+
cursor.setPosition(joined[0] + shift)
|
|
498
|
+
cursor.setPosition(joined[1] + shift, MM.KeepAnchor)
|
|
499
|
+
cursor.removeSelectedText()
|
|
500
|
+
|
|
501
|
+
with self.edit_session():
|
|
502
|
+
edit_joined_spans(self.citer, span, delete_join)
|
|
503
|
+
|
|
504
|
+
def copy(self) -> None:
|
|
505
|
+
"""Copy the text of every cursor to the clipboard, see cursors/clipboard.py"""
|
|
506
|
+
texts = self.cursors.selected_texts()
|
|
507
|
+
# Like Qt, copying nothing leaves the clipboard alone
|
|
508
|
+
if not any(texts):
|
|
509
|
+
return
|
|
510
|
+
if self.options["copy_indents_as_spaces"]:
|
|
511
|
+
width = self.options["space_indent_width"]
|
|
512
|
+
texts = [indent_tabs_to_spaces(t, width) for t in texts]
|
|
513
|
+
QtWidgets.QApplication.clipboard().setMimeData(encode_multi_copy(texts))
|
|
514
|
+
|
|
515
|
+
def cut(self) -> None:
|
|
516
|
+
self.copy()
|
|
517
|
+
with self.edit_session():
|
|
518
|
+
for cursor in self.citer.iterate_cursors(complete_all=True):
|
|
519
|
+
if cursor.hasSelection():
|
|
520
|
+
cursor.removeSelectedText()
|
|
521
|
+
|
|
522
|
+
def paste(self) -> None:
|
|
523
|
+
# An empty clipboard has no mime data on some platforms
|
|
524
|
+
source = QtWidgets.QApplication.clipboard().mimeData()
|
|
525
|
+
if source is not None:
|
|
526
|
+
self.insertFromMimeData(source)
|
|
527
|
+
|
|
528
|
+
def insertFromMimeData(self, source: QtCore.QMimeData) -> None:
|
|
529
|
+
"""Insert mime data at every cursor, through TrackedCursors"""
|
|
530
|
+
text = source.text()
|
|
531
|
+
if not text:
|
|
532
|
+
return
|
|
533
|
+
parts = split_multi_copy(source, len(self.cursors.secondary) + 1)
|
|
534
|
+
if parts is None:
|
|
535
|
+
self.insert_text(text)
|
|
536
|
+
return
|
|
537
|
+
with self.edit_session():
|
|
538
|
+
# There is a part for every cursor, so this never runs out
|
|
539
|
+
for i, cursor in enumerate(self.citer.iterate_cursors(complete_all=True)):
|
|
540
|
+
cursor.insertText(parts[i])
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""The editor's cursors
|
|
2
|
+
|
|
3
|
+
- state: CursorState, a cursor's anchor and position
|
|
4
|
+
- multi: MultiCursor, the editor's cursors (`editor.cursors`) and their commands
|
|
5
|
+
- iterator: CursorIterator, which runs an edit at every cursor (`editor.citer`)
|
|
6
|
+
- line_edits: multi-cursor edits that join the cursors sharing a line or span
|
|
7
|
+
- tracked: TrackedCursor, a QTextCursor that reports its edits to the parse tree
|
|
8
|
+
"""
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""The clipboard format for text copied from several cursors
|
|
2
|
+
|
|
3
|
+
The texts are joined with newlines as plain text, and their lengths are stored
|
|
4
|
+
alongside. Pasting with the same number of cursors gives each cursor its own text.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from Qt import QtCore
|
|
12
|
+
|
|
13
|
+
# The mime type that holds the lengths of the texts
|
|
14
|
+
MIME = "text/multi_copy_lengths"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def indent_tabs_to_spaces(text: str, width: int) -> str:
|
|
18
|
+
"""Replace the tabs in the indent of each line with spaces"""
|
|
19
|
+
spaces = " " * width
|
|
20
|
+
return re.sub(r"(?m)^\t+", lambda m: spaces * len(m.group()), text)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def encode_multi_copy(texts: list[str]) -> QtCore.QMimeData:
|
|
24
|
+
mime_data = QtCore.QMimeData()
|
|
25
|
+
mime_data.setData(MIME, ",".join(str(len(t)) for t in texts).encode())
|
|
26
|
+
mime_data.setText("\n".join(texts))
|
|
27
|
+
return mime_data
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def split_multi_copy(source: QtCore.QMimeData, cursor_count: int) -> list[str] | None:
|
|
31
|
+
"""Split the text copied from cursor_count cursors back into its parts
|
|
32
|
+
|
|
33
|
+
Returns None if the text wasn't copied from that many cursors, or if
|
|
34
|
+
another program put lengths in our format that can't be read
|
|
35
|
+
"""
|
|
36
|
+
if not source.hasFormat(MIME):
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
lens = [int(n) for n in bytes(source.data(MIME)).decode().split(",")]
|
|
40
|
+
except (UnicodeDecodeError, ValueError):
|
|
41
|
+
return None
|
|
42
|
+
text = source.text()
|
|
43
|
+
newline_separators = len(lens) - 1
|
|
44
|
+
if len(lens) != cursor_count or len(text) != sum(lens) + newline_separators:
|
|
45
|
+
return None
|
|
46
|
+
parts = []
|
|
47
|
+
ptr = 0
|
|
48
|
+
for length in lens:
|
|
49
|
+
parts.append(text[ptr : ptr + length])
|
|
50
|
+
ptr += length + 1
|
|
51
|
+
return parts
|