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,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