Coverage for vibefpmgui/multi_cursor.py: 89%
189 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""Several cursors at once in the editor (⌥-click, column selection).
3A part of `vibefpmgui.code_editor.CodeEditor`."""
5from __future__ import annotations
7import sys
9from qtpy.QtCore import Qt
10from qtpy.QtGui import QKeySequence, QTextCursor
11from qtpy.QtWidgets import QApplication
14class MultiCursorMixin:
15 """Several cursors at once (a part of `vibefpmgui.code_editor.CodeEditor`)."""
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())
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(): 28 ↛ 29line 28 didn't jump to line 29 because the condition on line 28 was never true
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()
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
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
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(): 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true
57 break
58 self.extra_cursors.append(on_line(block))
59 self._cursors_changed()
61 def clear_extra_cursors(self):
62 if self.extra_cursors:
63 self.extra_cursors = []
64 self._cursors_changed()
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()
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: 94 ↛ 97line 94 didn't jump to line 97 because the condition on line 94 was always true
95 self._caret_timer.start(flash // 2)
96 else:
97 self._caret_timer.stop()
98 self.viewport().update()
100 def _blink(self):
101 self._caret_on = not self._caret_on
102 self.viewport().update()
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()
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
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
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()
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): 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true
172 self.copy()
173 return True
174 if event.matches(QKeySequence.Cut): 174 ↛ 175line 174 didn't jump to line 175 because the condition on line 174 was never true
175 self.cut()
176 return True
177 if event.matches(QKeySequence.Paste): 177 ↛ 178line 177 didn't jump to line 178 because the condition on line 177 was never true
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 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true
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): 208 ↛ 211line 208 didn't jump to line 211 because the condition on line 208 was always true
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)
213 def copy(self):
214 if not self.extra_cursors: 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true
215 super().copy()
216 return
217 texts = [c.selectedText().replace("\u2029", "\n") for c in self.all_cursors() if c.hasSelection()]
218 if texts: 218 ↛ exitline 218 didn't return from function 'copy' because the condition on line 218 was always true
219 QApplication.clipboard().setText("\n".join(texts))
221 def cut(self):
222 if not self.extra_cursors: 222 ↛ 223line 222 didn't jump to line 223 because the condition on line 222 was never true
223 super().cut()
224 return
225 self.copy()
226 self._at_each_cursor(lambda c: c.removeSelectedText())
228 def paste(self):
229 if not self.extra_cursors: 229 ↛ 230line 229 didn't jump to line 230 because the condition on line 229 was never true
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
238 def insert(c):
239 c.insertText(pieces[c.position()] if pieces is not None and c.position() in pieces else text)
241 self._at_each_cursor(insert)