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

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(): 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() 

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(): 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() 

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: 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() 

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): 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) 

212 

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

220 

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()) 

227 

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 

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)