vibefpmgui.multi_cursor

Several cursors at once in the editor (⌥-click, column selection).

A part of vibefpmgui.code_editor.CodeEditor.

  1"""Several cursors at once in the editor (⌥-click, column selection).
  2
  3A part of `vibefpmgui.code_editor.CodeEditor`."""
  4
  5from __future__ import annotations
  6
  7import sys
  8
  9from qtpy.QtCore import Qt
 10from qtpy.QtGui import QKeySequence, QTextCursor
 11from qtpy.QtWidgets import QApplication
 12
 13
 14class MultiCursorMixin:
 15    """Several cursors at once (a part of `vibefpmgui.code_editor.CodeEditor`)."""
 16
 17    def all_cursors(self) -> list[QTextCursor]:
 18        """Every cursor, the main one included, in the order they're in the text."""
 19        return sorted([self.textCursor(), *self.extra_cursors], key=lambda c: c.position())
 20
 21    def toggle_cursor_at(self, position: int):
 22        """⌥/Alt-click: add a cursor there, or remove the one that's there."""
 23        for c in self.extra_cursors:
 24            if c.position() == position:
 25                self.extra_cursors.remove(c)
 26                self._cursors_changed()
 27                return
 28        if position == self.textCursor().position() and not self.textCursor().hasSelection():
 29            return  # (the main cursor is already there)
 30        cursor = QTextCursor(self.document())
 31        cursor.setPosition(position)
 32        self.extra_cursors.append(cursor)
 33        self._cursors_changed()
 34
 35    def add_column_cursors(self, to_line: int, to_column: int | None = None):
 36        """⌘⇧/Ctrl+Shift-click: a cursor on each line from the main cursor's
 37        line to ``to_line`` (0-based). Clicked in the main cursor's column:
 38        cursors in that column; in another (``to_column``): a box selection,
 39        each line selected from the one column to the other, its cursor at
 40        the clicked end (both clipped to a shorter line's end)."""
 41        main = self.textCursor()
 42        first, column = main.blockNumber(), main.positionInBlock()
 43        end_column = column if to_column is None else to_column
 44
 45        def on_line(block) -> QTextCursor:
 46            last = block.length() - 1  # (the line's end, before the line break)
 47            cursor = QTextCursor(block)
 48            cursor.setPosition(block.position() + min(column, last))
 49            cursor.setPosition(block.position() + min(end_column, last), QTextCursor.KeepAnchor)
 50            return cursor
 51
 52        self.setTextCursor(on_line(main.block()))
 53        step = 1 if to_line >= first else -1
 54        for number in range(first + step, to_line + step, step):
 55            block = self.document().findBlockByNumber(number)
 56            if not block.isValid():
 57                break
 58            self.extra_cursors.append(on_line(block))
 59        self._cursors_changed()
 60
 61    def clear_extra_cursors(self):
 62        if self.extra_cursors:
 63            self.extra_cursors = []
 64            self._cursors_changed()
 65
 66    def _cursors_changed(self):
 67        # Two cursors on the same spot are one (e.g. after deleting between them).
 68        seen = {(self.textCursor().position(), self.textCursor().anchor())}
 69        kept = []
 70        for c in self.extra_cursors:
 71            key = (c.position(), c.anchor())
 72            if key not in seen:
 73                seen.add(key)
 74                kept.append(c)
 75        self.extra_cursors = kept
 76        if kept and self.cursorWidth():  # several cursors: draw them all ourselves
 77            self._caret_width = self.cursorWidth()
 78            self.setCursorWidth(0)
 79        elif not kept and not self.cursorWidth():  # one again: Qt's own
 80            self.setCursorWidth(self._caret_width)
 81        self._restart_blink()
 82        self.update_extra_selections(rebuild=False)  # (the cursors' selections are always made anew)
 83        self.viewport().update()
 84        self.cursors_changed.emit()
 85
 86    def _restart_blink(self):
 87        """Typing or moving shows the cursors and starts the blink again (as
 88        Qt does with its own)."""
 89        if not self.extra_cursors:
 90            self._caret_timer.stop()
 91            return
 92        self._caret_on = True
 93        flash = QApplication.cursorFlashTime()  # (a whole on-off cycle; 0: no blinking)
 94        if flash > 0:
 95            self._caret_timer.start(flash // 2)
 96        else:
 97            self._caret_timer.stop()
 98        self.viewport().update()
 99
100    def _blink(self):
101        self._caret_on = not self._caret_on
102        self.viewport().update()
103
104    def _at_each_cursor(self, edit):
105        """``edit(cursor)`` at every cursor, as one Undo step."""
106        main = self.textCursor()
107        cursors = [main, *self.extra_cursors]
108        main.beginEditBlock()  # (the document's: every cursor's edits are in it)
109        for c in sorted(cursors, key=lambda c: c.position(), reverse=True):
110            edit(c)
111        main.endEditBlock()
112        self.setTextCursor(main)
113        self._cursors_changed()
114
115    def visual_column(self, cursor: QTextCursor) -> int:
116        """The column a position shows at (a tab: to the next tab stop)."""
117        tab = max(1, round(self.tabStopDistance() / max(1, self.fontMetrics().horizontalAdvance(" "))))
118        column = 0
119        for ch in cursor.block().text()[:cursor.positionInBlock()]:
120            column = (column // tab + 1) * tab if ch == "\t" else column + 1
121        return column
122
123    def align_cursors(self) -> bool:
124        """Edit → Lines → Align Cursors: spaces before each cursor left of the
125        right-most one, so they're all in its column (one Undo step). Several
126        cursors on a line: the first on each line aligned, then the second, and
127        so on. A cursor with a selection: by its left edge (the selection moves
128        along). False: fewer than two cursors (nothing to align)."""
129        cursors = self.all_cursors()
130        if len(cursors) < 2:
131            return False
132        lines: dict[int, list[QTextCursor]] = {}
133        for c in cursors:
134            edge = QTextCursor(c)  # (where the spaces go: a selection's left edge)
135            edge.setPosition(c.selectionStart())
136            lines.setdefault(edge.blockNumber(), []).append(edge)
137        for row in lines.values():
138            row.sort(key=lambda c: c.position())
139        main = self.textCursor()
140        main.beginEditBlock()
141        for k in range(max(len(row) for row in lines.values())):
142            column_k = [row[k] for row in lines.values() if len(row) > k]
143            target = max(self.visual_column(c) for c in column_k)
144            for c in column_k:
145                gap = target - self.visual_column(c)
146                if gap > 0:
147                    c.insertText(" " * gap)  # (the cursors after it on the line move along)
148        main.endEditBlock()
149        self.setTextCursor(main)
150        self._cursors_changed()
151        return True
152
153    def _move_each_cursor(self, operation, select: bool):
154        mode = QTextCursor.KeepAnchor if select else QTextCursor.MoveAnchor
155        main = self.textCursor()
156        for c in (main, *self.extra_cursors):
157            c.movePosition(operation, mode)
158        self.setTextCursor(main)
159        self._cursors_changed()
160
161    def _multi_cursor_key(self, event) -> bool:
162        """A key with several cursors: True if it was handled here."""
163        self.completion_popup.hide()
164        self.signature_popup.hide()
165        key, mods = event.key(), event.modifiers()
166        shift = bool(mods & Qt.ShiftModifier)
167        word = bool(mods & (Qt.AltModifier if sys.platform == "darwin" else Qt.ControlModifier))
168        if key == Qt.Key_Escape:
169            self.clear_extra_cursors()
170            return True
171        if event.matches(QKeySequence.Copy):
172            self.copy()
173            return True
174        if event.matches(QKeySequence.Cut):
175            self.cut()
176            return True
177        if event.matches(QKeySequence.Paste):
178            self.paste()
179            return True
180        moves = {Qt.Key_Left: QTextCursor.WordLeft if word else QTextCursor.Left,
181                 Qt.Key_Right: QTextCursor.WordRight if word else QTextCursor.Right,
182                 Qt.Key_Up: QTextCursor.Up, Qt.Key_Down: QTextCursor.Down,
183                 Qt.Key_Home: QTextCursor.StartOfBlock, Qt.Key_End: QTextCursor.EndOfBlock}
184        command = sys.platform == "darwin" and mods & Qt.ControlModifier
185        if command and key in (Qt.Key_Left, Qt.Key_Right):  # ⌘← / ⌘→: the start / end of the line
186            self._move_each_cursor(QTextCursor.StartOfBlock if key == Qt.Key_Left else QTextCursor.EndOfBlock,
187                                   shift)
188            return True
189        if key in moves and not command:
190            self._move_each_cursor(moves[key], shift)
191            return True
192        if key == Qt.Key_Backspace:
193            self._at_each_cursor(lambda c: c.removeSelectedText() if c.hasSelection() else c.deletePreviousChar())
194            return True
195        if key == Qt.Key_Delete:
196            self._at_each_cursor(lambda c: c.removeSelectedText() if c.hasSelection() else c.deleteChar())
197            return True
198        if key in (Qt.Key_Return, Qt.Key_Enter):
199            def newline(c):
200                line = c.block().text()
201                c.insertText("\n" + line[:len(line) - len(line.lstrip())])  # (keeping the indentation)
202            self._at_each_cursor(newline)
203            return True
204        if key == Qt.Key_Tab:
205            self._at_each_cursor(lambda c: c.insertText(self.tab_text(c)))
206            return True
207        text = event.text()
208        if text and text.isprintable() and not mods & (Qt.ControlModifier | Qt.MetaModifier):
209            self._at_each_cursor(lambda c: c.insertText(text))
210            return True
211        return False  # (other keys: shortcuts and the like, for the main cursor)
212
213    def copy(self):
214        if not self.extra_cursors:
215            super().copy()
216            return
217        texts = [c.selectedText().replace("\u2029", "\n") for c in self.all_cursors() if c.hasSelection()]
218        if texts:
219            QApplication.clipboard().setText("\n".join(texts))
220
221    def cut(self):
222        if not self.extra_cursors:
223            super().cut()
224            return
225        self.copy()
226        self._at_each_cursor(lambda c: c.removeSelectedText())
227
228    def paste(self):
229        if not self.extra_cursors:
230            super().paste()
231            return
232        text = QApplication.clipboard().text().replace("\r\n", "\n")
233        lines = text.split("\n")
234        cursors = self.all_cursors()
235        # As many lines as cursors: one each (e.g. copied with these cursors).
236        pieces = dict(zip((c.position() for c in cursors), lines)) if len(lines) == len(cursors) else None
237
238        def insert(c):
239            c.insertText(pieces[c.position()] if pieces is not None and c.position() in pieces else text)
240
241        self._at_each_cursor(insert)
class MultiCursorMixin:
 15class MultiCursorMixin:
 16    """Several cursors at once (a part of `vibefpmgui.code_editor.CodeEditor`)."""
 17
 18    def all_cursors(self) -> list[QTextCursor]:
 19        """Every cursor, the main one included, in the order they're in the text."""
 20        return sorted([self.textCursor(), *self.extra_cursors], key=lambda c: c.position())
 21
 22    def toggle_cursor_at(self, position: int):
 23        """⌥/Alt-click: add a cursor there, or remove the one that's there."""
 24        for c in self.extra_cursors:
 25            if c.position() == position:
 26                self.extra_cursors.remove(c)
 27                self._cursors_changed()
 28                return
 29        if position == self.textCursor().position() and not self.textCursor().hasSelection():
 30            return  # (the main cursor is already there)
 31        cursor = QTextCursor(self.document())
 32        cursor.setPosition(position)
 33        self.extra_cursors.append(cursor)
 34        self._cursors_changed()
 35
 36    def add_column_cursors(self, to_line: int, to_column: int | None = None):
 37        """⌘⇧/Ctrl+Shift-click: a cursor on each line from the main cursor's
 38        line to ``to_line`` (0-based). Clicked in the main cursor's column:
 39        cursors in that column; in another (``to_column``): a box selection,
 40        each line selected from the one column to the other, its cursor at
 41        the clicked end (both clipped to a shorter line's end)."""
 42        main = self.textCursor()
 43        first, column = main.blockNumber(), main.positionInBlock()
 44        end_column = column if to_column is None else to_column
 45
 46        def on_line(block) -> QTextCursor:
 47            last = block.length() - 1  # (the line's end, before the line break)
 48            cursor = QTextCursor(block)
 49            cursor.setPosition(block.position() + min(column, last))
 50            cursor.setPosition(block.position() + min(end_column, last), QTextCursor.KeepAnchor)
 51            return cursor
 52
 53        self.setTextCursor(on_line(main.block()))
 54        step = 1 if to_line >= first else -1
 55        for number in range(first + step, to_line + step, step):
 56            block = self.document().findBlockByNumber(number)
 57            if not block.isValid():
 58                break
 59            self.extra_cursors.append(on_line(block))
 60        self._cursors_changed()
 61
 62    def clear_extra_cursors(self):
 63        if self.extra_cursors:
 64            self.extra_cursors = []
 65            self._cursors_changed()
 66
 67    def _cursors_changed(self):
 68        # Two cursors on the same spot are one (e.g. after deleting between them).
 69        seen = {(self.textCursor().position(), self.textCursor().anchor())}
 70        kept = []
 71        for c in self.extra_cursors:
 72            key = (c.position(), c.anchor())
 73            if key not in seen:
 74                seen.add(key)
 75                kept.append(c)
 76        self.extra_cursors = kept
 77        if kept and self.cursorWidth():  # several cursors: draw them all ourselves
 78            self._caret_width = self.cursorWidth()
 79            self.setCursorWidth(0)
 80        elif not kept and not self.cursorWidth():  # one again: Qt's own
 81            self.setCursorWidth(self._caret_width)
 82        self._restart_blink()
 83        self.update_extra_selections(rebuild=False)  # (the cursors' selections are always made anew)
 84        self.viewport().update()
 85        self.cursors_changed.emit()
 86
 87    def _restart_blink(self):
 88        """Typing or moving shows the cursors and starts the blink again (as
 89        Qt does with its own)."""
 90        if not self.extra_cursors:
 91            self._caret_timer.stop()
 92            return
 93        self._caret_on = True
 94        flash = QApplication.cursorFlashTime()  # (a whole on-off cycle; 0: no blinking)
 95        if flash > 0:
 96            self._caret_timer.start(flash // 2)
 97        else:
 98            self._caret_timer.stop()
 99        self.viewport().update()
100
101    def _blink(self):
102        self._caret_on = not self._caret_on
103        self.viewport().update()
104
105    def _at_each_cursor(self, edit):
106        """``edit(cursor)`` at every cursor, as one Undo step."""
107        main = self.textCursor()
108        cursors = [main, *self.extra_cursors]
109        main.beginEditBlock()  # (the document's: every cursor's edits are in it)
110        for c in sorted(cursors, key=lambda c: c.position(), reverse=True):
111            edit(c)
112        main.endEditBlock()
113        self.setTextCursor(main)
114        self._cursors_changed()
115
116    def visual_column(self, cursor: QTextCursor) -> int:
117        """The column a position shows at (a tab: to the next tab stop)."""
118        tab = max(1, round(self.tabStopDistance() / max(1, self.fontMetrics().horizontalAdvance(" "))))
119        column = 0
120        for ch in cursor.block().text()[:cursor.positionInBlock()]:
121            column = (column // tab + 1) * tab if ch == "\t" else column + 1
122        return column
123
124    def align_cursors(self) -> bool:
125        """Edit → Lines → Align Cursors: spaces before each cursor left of the
126        right-most one, so they're all in its column (one Undo step). Several
127        cursors on a line: the first on each line aligned, then the second, and
128        so on. A cursor with a selection: by its left edge (the selection moves
129        along). False: fewer than two cursors (nothing to align)."""
130        cursors = self.all_cursors()
131        if len(cursors) < 2:
132            return False
133        lines: dict[int, list[QTextCursor]] = {}
134        for c in cursors:
135            edge = QTextCursor(c)  # (where the spaces go: a selection's left edge)
136            edge.setPosition(c.selectionStart())
137            lines.setdefault(edge.blockNumber(), []).append(edge)
138        for row in lines.values():
139            row.sort(key=lambda c: c.position())
140        main = self.textCursor()
141        main.beginEditBlock()
142        for k in range(max(len(row) for row in lines.values())):
143            column_k = [row[k] for row in lines.values() if len(row) > k]
144            target = max(self.visual_column(c) for c in column_k)
145            for c in column_k:
146                gap = target - self.visual_column(c)
147                if gap > 0:
148                    c.insertText(" " * gap)  # (the cursors after it on the line move along)
149        main.endEditBlock()
150        self.setTextCursor(main)
151        self._cursors_changed()
152        return True
153
154    def _move_each_cursor(self, operation, select: bool):
155        mode = QTextCursor.KeepAnchor if select else QTextCursor.MoveAnchor
156        main = self.textCursor()
157        for c in (main, *self.extra_cursors):
158            c.movePosition(operation, mode)
159        self.setTextCursor(main)
160        self._cursors_changed()
161
162    def _multi_cursor_key(self, event) -> bool:
163        """A key with several cursors: True if it was handled here."""
164        self.completion_popup.hide()
165        self.signature_popup.hide()
166        key, mods = event.key(), event.modifiers()
167        shift = bool(mods & Qt.ShiftModifier)
168        word = bool(mods & (Qt.AltModifier if sys.platform == "darwin" else Qt.ControlModifier))
169        if key == Qt.Key_Escape:
170            self.clear_extra_cursors()
171            return True
172        if event.matches(QKeySequence.Copy):
173            self.copy()
174            return True
175        if event.matches(QKeySequence.Cut):
176            self.cut()
177            return True
178        if event.matches(QKeySequence.Paste):
179            self.paste()
180            return True
181        moves = {Qt.Key_Left: QTextCursor.WordLeft if word else QTextCursor.Left,
182                 Qt.Key_Right: QTextCursor.WordRight if word else QTextCursor.Right,
183                 Qt.Key_Up: QTextCursor.Up, Qt.Key_Down: QTextCursor.Down,
184                 Qt.Key_Home: QTextCursor.StartOfBlock, Qt.Key_End: QTextCursor.EndOfBlock}
185        command = sys.platform == "darwin" and mods & Qt.ControlModifier
186        if command and key in (Qt.Key_Left, Qt.Key_Right):  # ⌘← / ⌘→: the start / end of the line
187            self._move_each_cursor(QTextCursor.StartOfBlock if key == Qt.Key_Left else QTextCursor.EndOfBlock,
188                                   shift)
189            return True
190        if key in moves and not command:
191            self._move_each_cursor(moves[key], shift)
192            return True
193        if key == Qt.Key_Backspace:
194            self._at_each_cursor(lambda c: c.removeSelectedText() if c.hasSelection() else c.deletePreviousChar())
195            return True
196        if key == Qt.Key_Delete:
197            self._at_each_cursor(lambda c: c.removeSelectedText() if c.hasSelection() else c.deleteChar())
198            return True
199        if key in (Qt.Key_Return, Qt.Key_Enter):
200            def newline(c):
201                line = c.block().text()
202                c.insertText("\n" + line[:len(line) - len(line.lstrip())])  # (keeping the indentation)
203            self._at_each_cursor(newline)
204            return True
205        if key == Qt.Key_Tab:
206            self._at_each_cursor(lambda c: c.insertText(self.tab_text(c)))
207            return True
208        text = event.text()
209        if text and text.isprintable() and not mods & (Qt.ControlModifier | Qt.MetaModifier):
210            self._at_each_cursor(lambda c: c.insertText(text))
211            return True
212        return False  # (other keys: shortcuts and the like, for the main cursor)
213
214    def copy(self):
215        if not self.extra_cursors:
216            super().copy()
217            return
218        texts = [c.selectedText().replace("\u2029", "\n") for c in self.all_cursors() if c.hasSelection()]
219        if texts:
220            QApplication.clipboard().setText("\n".join(texts))
221
222    def cut(self):
223        if not self.extra_cursors:
224            super().cut()
225            return
226        self.copy()
227        self._at_each_cursor(lambda c: c.removeSelectedText())
228
229    def paste(self):
230        if not self.extra_cursors:
231            super().paste()
232            return
233        text = QApplication.clipboard().text().replace("\r\n", "\n")
234        lines = text.split("\n")
235        cursors = self.all_cursors()
236        # As many lines as cursors: one each (e.g. copied with these cursors).
237        pieces = dict(zip((c.position() for c in cursors), lines)) if len(lines) == len(cursors) else None
238
239        def insert(c):
240            c.insertText(pieces[c.position()] if pieces is not None and c.position() in pieces else text)
241
242        self._at_each_cursor(insert)

Several cursors at once (a part of vibefpmgui.code_editor.CodeEditor).

def all_cursors(self) -> list[PySide6.QtGui.QTextCursor]:
18    def all_cursors(self) -> list[QTextCursor]:
19        """Every cursor, the main one included, in the order they're in the text."""
20        return sorted([self.textCursor(), *self.extra_cursors], key=lambda c: c.position())

Every cursor, the main one included, in the order they're in the text.

def toggle_cursor_at(self, position: int):
22    def toggle_cursor_at(self, position: int):
23        """⌥/Alt-click: add a cursor there, or remove the one that's there."""
24        for c in self.extra_cursors:
25            if c.position() == position:
26                self.extra_cursors.remove(c)
27                self._cursors_changed()
28                return
29        if position == self.textCursor().position() and not self.textCursor().hasSelection():
30            return  # (the main cursor is already there)
31        cursor = QTextCursor(self.document())
32        cursor.setPosition(position)
33        self.extra_cursors.append(cursor)
34        self._cursors_changed()

⌥/Alt-click: add a cursor there, or remove the one that's there.

def add_column_cursors(self, to_line: int, to_column: int | None = None):
36    def add_column_cursors(self, to_line: int, to_column: int | None = None):
37        """⌘⇧/Ctrl+Shift-click: a cursor on each line from the main cursor's
38        line to ``to_line`` (0-based). Clicked in the main cursor's column:
39        cursors in that column; in another (``to_column``): a box selection,
40        each line selected from the one column to the other, its cursor at
41        the clicked end (both clipped to a shorter line's end)."""
42        main = self.textCursor()
43        first, column = main.blockNumber(), main.positionInBlock()
44        end_column = column if to_column is None else to_column
45
46        def on_line(block) -> QTextCursor:
47            last = block.length() - 1  # (the line's end, before the line break)
48            cursor = QTextCursor(block)
49            cursor.setPosition(block.position() + min(column, last))
50            cursor.setPosition(block.position() + min(end_column, last), QTextCursor.KeepAnchor)
51            return cursor
52
53        self.setTextCursor(on_line(main.block()))
54        step = 1 if to_line >= first else -1
55        for number in range(first + step, to_line + step, step):
56            block = self.document().findBlockByNumber(number)
57            if not block.isValid():
58                break
59            self.extra_cursors.append(on_line(block))
60        self._cursors_changed()

⌘⇧/Ctrl+Shift-click: a cursor on each line from the main cursor's line to to_line (0-based). Clicked in the main cursor's column: cursors in that column; in another (to_column): a box selection, each line selected from the one column to the other, its cursor at the clicked end (both clipped to a shorter line's end).

def clear_extra_cursors(self):
62    def clear_extra_cursors(self):
63        if self.extra_cursors:
64            self.extra_cursors = []
65            self._cursors_changed()
def visual_column(self, cursor: PySide6.QtGui.QTextCursor) -> int:
116    def visual_column(self, cursor: QTextCursor) -> int:
117        """The column a position shows at (a tab: to the next tab stop)."""
118        tab = max(1, round(self.tabStopDistance() / max(1, self.fontMetrics().horizontalAdvance(" "))))
119        column = 0
120        for ch in cursor.block().text()[:cursor.positionInBlock()]:
121            column = (column // tab + 1) * tab if ch == "\t" else column + 1
122        return column

The column a position shows at (a tab: to the next tab stop).

def align_cursors(self) -> bool:
124    def align_cursors(self) -> bool:
125        """Edit → Lines → Align Cursors: spaces before each cursor left of the
126        right-most one, so they're all in its column (one Undo step). Several
127        cursors on a line: the first on each line aligned, then the second, and
128        so on. A cursor with a selection: by its left edge (the selection moves
129        along). False: fewer than two cursors (nothing to align)."""
130        cursors = self.all_cursors()
131        if len(cursors) < 2:
132            return False
133        lines: dict[int, list[QTextCursor]] = {}
134        for c in cursors:
135            edge = QTextCursor(c)  # (where the spaces go: a selection's left edge)
136            edge.setPosition(c.selectionStart())
137            lines.setdefault(edge.blockNumber(), []).append(edge)
138        for row in lines.values():
139            row.sort(key=lambda c: c.position())
140        main = self.textCursor()
141        main.beginEditBlock()
142        for k in range(max(len(row) for row in lines.values())):
143            column_k = [row[k] for row in lines.values() if len(row) > k]
144            target = max(self.visual_column(c) for c in column_k)
145            for c in column_k:
146                gap = target - self.visual_column(c)
147                if gap > 0:
148                    c.insertText(" " * gap)  # (the cursors after it on the line move along)
149        main.endEditBlock()
150        self.setTextCursor(main)
151        self._cursors_changed()
152        return True

Edit → Lines → Align Cursors: spaces before each cursor left of the right-most one, so they're all in its column (one Undo step). Several cursors on a line: the first on each line aligned, then the second, and so on. A cursor with a selection: by its left edge (the selection moves along). False: fewer than two cursors (nothing to align).

def copy(self):
214    def copy(self):
215        if not self.extra_cursors:
216            super().copy()
217            return
218        texts = [c.selectedText().replace("\u2029", "\n") for c in self.all_cursors() if c.hasSelection()]
219        if texts:
220            QApplication.clipboard().setText("\n".join(texts))
def cut(self):
222    def cut(self):
223        if not self.extra_cursors:
224            super().cut()
225            return
226        self.copy()
227        self._at_each_cursor(lambda c: c.removeSelectedText())
def paste(self):
229    def paste(self):
230        if not self.extra_cursors:
231            super().paste()
232            return
233        text = QApplication.clipboard().text().replace("\r\n", "\n")
234        lines = text.split("\n")
235        cursors = self.all_cursors()
236        # As many lines as cursors: one each (e.g. copied with these cursors).
237        pieces = dict(zip((c.position() for c in cursors), lines)) if len(lines) == len(cursors) else None
238
239        def insert(c):
240            c.insertText(pieces[c.position()] if pieces is not None and c.position() in pieces else text)
241
242        self._at_each_cursor(insert)