vibefpmgui.line_edits
Edit menu commands on lines and selections: Toggle Line Comment, Move Line Up / Down, Sort Lines, Change Case.
The text work is in plain functions (no Qt: toggle_comment, sort_lines,
change_case); LineEditsMixin (a part of vibefpmgui.code_editor.CodeEditor)
applies them to the document, each as one step to undo.
1"""Edit menu commands on lines and selections: Toggle Line Comment, Move 2Line Up / Down, Sort Lines, Change Case. 3 4The text work is in plain functions (no Qt: `toggle_comment`, `sort_lines`, 5`change_case`); `LineEditsMixin` (a part of `vibefpmgui.code_editor.CodeEditor`) 6applies them to the document, each as one step to undo. 7""" 8 9from __future__ import annotations 10 11import re 12from pathlib import Path 13 14from qtpy.QtGui import QTextCursor 15 16from .complexity import FIXED_FORM, FORTRAN 17 18# A file's line comment, by its suffix or name ("": none, e.g. Markdown's). 19HASH_COMMENTS = {".toml", ".py", ".sh", ".bash", ".zsh", ".yaml", ".yml", ".cmake", ".mk", ".cfg", ".ini", 20 ".r", ".pl", ".rb", ".nml"} 21SLASH_COMMENTS = {".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".hh", ".js", ".ts", ".java", ".cs", ".go", ".rs", 22 ".swift", ".kt", ".cu"} 23HASH_NAMES = {"makefile", "gnumakefile", "cmakelists.txt", "dockerfile", ".gitignore"} 24 25 26def comment_style(path: Path | str) -> tuple[str, bool]: 27 """(the line comment for this file, whether it's fixed-form Fortran): 28 "!" for Fortran, "#" for TOML, Python, shell…, "//" for C and the like, 29 "" for a file without a line comment.""" 30 path = Path(path) 31 suffix = path.suffix.lower() 32 if suffix in FORTRAN or suffix in (".inc", ".fi", ".fypp"): 33 return "!", suffix in FIXED_FORM 34 if suffix in HASH_COMMENTS or path.name.lower() in HASH_NAMES: 35 return "#", False 36 if suffix in SLASH_COMMENTS: 37 return "//", False 38 return "", False 39 40 41def _commented(line: str, prefix: str, fixed: bool) -> bool: 42 if fixed: 43 return line.startswith(prefix) 44 rest = line.lstrip() 45 # (A directive, as "!$omp parallel", is code, not a comment.) 46 return rest.startswith(prefix) and not (prefix == "!" and rest.startswith("!$")) 47 48 49def toggle_comment(lines: list[str], prefix: str, fixed: bool = False) -> list[str]: 50 """The lines commented out, or, if every one with code already is, back 51 as they were. A comment goes at the lines' least indentation ("! x = 1"), 52 or, in fixed form, in the first column ("!" there is a comment; at the 53 sixth it would make the line a continuation). Blank lines stay blank.""" 54 code = [line for line in lines if line.strip()] 55 if not prefix or not code: 56 return list(lines) 57 if all(_commented(line, prefix, fixed) for line in code): 58 out = [] 59 for line in lines: 60 if not line.strip() or not _commented(line, prefix, fixed): 61 out.append(line) 62 elif fixed: 63 out.append(line[len(prefix):]) 64 else: 65 start = len(line) - len(line.lstrip()) 66 rest = line[start + len(prefix):] 67 out.append(line[:start] + (rest.removeprefix(" "))) 68 return out 69 if fixed: 70 return [prefix + line if line.strip() else line for line in lines] 71 indent = min(len(line) - len(line.lstrip()) for line in code) 72 return [line[:indent] + prefix + " " + line[indent:] if line.strip() else line for line in lines] 73 74 75def sort_lines(lines: list[str], descending: bool = False) -> list[str]: 76 """The lines in order, ignoring case (and, where only case differs, as given).""" 77 return sorted(lines, key=lambda line: (line.casefold(), line), reverse=descending) 78 79 80UPPER, LOWER, TITLE = "upper", "lower", "title" 81 82 83def change_case(text: str, case: str) -> str: 84 """UPPER CASE, lower case, or Title Case (each word's first letter, the rest lower).""" 85 if case == UPPER: 86 return text.upper() 87 if case == LOWER: 88 return text.lower() 89 return re.sub(r"[^\W_]+", lambda m: m.group()[:1].upper() + m.group()[1:].lower(), text) 90 91 92def _qt_length(text: str) -> int: 93 """Its length in the document's positions (UTF-16: a character beyond U+FFFF counts twice).""" 94 return len(text) if text.isascii() else len(text.encode("utf-16-le")) // 2 95 96 97class LineEditsMixin: 98 """Toggle Line Comment, Move Line Up / Down, Sort Lines and Change Case 99 (a part of `vibefpmgui.code_editor.CodeEditor`).""" 100 101 comment_prefix = "!" # this file's line comment ("" for none: see comment_style) 102 fixed_form = False # fixed-form Fortran (its comments go in the first column) 103 104 def _edit_cursor(self) -> QTextCursor: 105 """A cursor for an edit, where the user's is: an Undo puts the cursor 106 back where its edit began (one at the document's start sent it to 107 the top of the file).""" 108 return QTextCursor(self.textCursor()) 109 110 def _line_span(self, cursor: QTextCursor) -> tuple[int, int]: 111 """The lines (0-based, first and last) a cursor's selection is on: not 112 the last if the selection only reaches its start.""" 113 doc = self.document() 114 start, end = sorted((cursor.anchor(), cursor.position())) 115 first = doc.findBlock(start).blockNumber() 116 last_block = doc.findBlock(end) 117 last = last_block.blockNumber() 118 if last > first and end == last_block.position(): 119 last -= 1 120 return first, last 121 122 def _lines(self, first: int, last: int) -> list[str]: 123 doc = self.document() 124 return [doc.findBlockByNumber(n).text() for n in range(first, last + 1)] 125 126 def _replace_lines(self, cursor: QTextCursor, first: int, last: int, lines: list[str]): 127 """Lines ``first`` to ``last`` become ``lines`` (as many), with ``cursor``.""" 128 doc = self.document() 129 end = doc.findBlockByNumber(last) 130 cursor.setPosition(doc.findBlockByNumber(first).position()) 131 cursor.setPosition(end.position() + end.length() - 1, QTextCursor.KeepAnchor) 132 cursor.insertText("\n".join(lines)) 133 134 def toggle_comment(self): 135 """Edit → Toggle Line Comment: the lines of the selection (and of the 136 other cursors'), commented out or back.""" 137 if not self.comment_prefix: 138 return 139 spans = sorted({self._line_span(c) for c in [self.textCursor(), *self.extra_cursors]}) 140 lines = sorted({n for first, last in spans for n in range(first, last + 1)}) 141 if not lines: 142 return 143 all_lines = self._lines(lines[0], lines[-1]) 144 chosen = [all_lines[n - lines[0]] for n in lines] 145 changed = toggle_comment(chosen, self.comment_prefix, self.fixed_form) 146 if changed == chosen: 147 return 148 new = list(all_lines) 149 for n, line in zip(lines, changed, strict=True): 150 new[n - lines[0]] = line 151 main = self.textCursor() 152 line, column = main.blockNumber(), main.positionInBlock() 153 anchor_block = self.document().findBlock(main.anchor()) 154 anchor_line, anchor_column = anchor_block.blockNumber(), main.anchor() - anchor_block.position() 155 cursor = self._edit_cursor() 156 cursor.beginEditBlock() 157 self._replace_lines(cursor, lines[0], lines[-1], new) 158 cursor.endEditBlock() 159 # The selection stays on the same lines (its columns moved by what was put in or taken out). 160 self._select_columns(anchor_line, anchor_column, line, column, all_lines, new, lines[0]) 161 162 def _select_columns(self, anchor_line, anchor_column, line, column, old, new, first): 163 def moved(n: int, col: int) -> int: 164 if not first <= n < first + len(old): 165 return col 166 before, after = old[n - first], new[n - first] 167 shift = _qt_length(after) - _qt_length(before) 168 indent = _qt_length(before) - _qt_length(before.lstrip()) 169 return max(0, col + shift) if col >= indent or self.fixed_form else col 170 171 doc = self.document() 172 cursor = QTextCursor(doc) 173 block = doc.findBlockByNumber(anchor_line) 174 cursor.setPosition(block.position() + min(moved(anchor_line, anchor_column), block.length() - 1)) 175 block = doc.findBlockByNumber(line) 176 cursor.setPosition(block.position() + min(moved(line, column), block.length() - 1), QTextCursor.KeepAnchor) 177 self.setTextCursor(cursor) 178 179 def move_lines(self, down: bool): 180 """Edit → Lines → Move Line Up / Down: the selection's lines swap 181 places with the line above or below, the selection going with them.""" 182 doc = self.document() 183 main = self.textCursor() 184 first, last = self._line_span(main) 185 if (not down and first == 0) or (down and last >= doc.blockCount() - 1): 186 return 187 lines = self._lines(first, last) 188 if down: 189 neighbour = doc.findBlockByNumber(last + 1) 190 new, span, shift = [neighbour.text(), *lines], (first, last + 1), neighbour.length() 191 else: 192 neighbour = doc.findBlockByNumber(first - 1) 193 new, span, shift = [*lines, neighbour.text()], (first - 1, last), -neighbour.length() 194 anchor, position = main.anchor(), main.position() 195 cursor = self._edit_cursor() 196 cursor.beginEditBlock() 197 self._replace_lines(cursor, *span, new) 198 cursor.endEditBlock() 199 moved = QTextCursor(doc) 200 moved.setPosition(anchor + shift) 201 moved.setPosition(position + shift, QTextCursor.KeepAnchor) 202 self.setTextCursor(moved) 203 204 def sort_selected_lines(self, descending: bool = False): 205 """Edit → Lines → Sort Lines: the selection's lines (two or more) in order.""" 206 first, last = self._line_span(self.textCursor()) 207 if last <= first: 208 return 209 lines = self._lines(first, last) 210 ordered = sort_lines(lines, descending) 211 if ordered == lines: 212 return 213 cursor = self._edit_cursor() 214 cursor.beginEditBlock() 215 self._replace_lines(cursor, first, last, ordered) 216 cursor.endEditBlock() 217 doc = self.document() 218 end = doc.findBlockByNumber(last) 219 selection = QTextCursor(doc) 220 selection.setPosition(doc.findBlockByNumber(first).position()) 221 selection.setPosition(end.position() + end.length() - 1, QTextCursor.KeepAnchor) 222 self.setTextCursor(selection) 223 224 def change_selection_case(self, case: str): 225 """Edit → Change Case: each selection (the main one and the other 226 cursors'), or the word at a cursor without one.""" 227 cursors = [self.textCursor(), *self.extra_cursors] 228 edit = self._edit_cursor() 229 edit.beginEditBlock() 230 for c in cursors: 231 target = QTextCursor(c) 232 if not target.hasSelection(): 233 target.select(QTextCursor.WordUnderCursor) 234 text = target.selectedText() 235 new = change_case(text, case) 236 if text and new != text: 237 selected = c.hasSelection() 238 start = target.selectionStart() 239 target.insertText(new) 240 if selected: # (still selected; its length may change, as "ß" → "SS") 241 c.setPosition(start) 242 c.setPosition(start + _qt_length(new), QTextCursor.KeepAnchor) 243 edit.endEditBlock() 244 self.setTextCursor(cursors[0])
27def comment_style(path: Path | str) -> tuple[str, bool]: 28 """(the line comment for this file, whether it's fixed-form Fortran): 29 "!" for Fortran, "#" for TOML, Python, shell…, "//" for C and the like, 30 "" for a file without a line comment.""" 31 path = Path(path) 32 suffix = path.suffix.lower() 33 if suffix in FORTRAN or suffix in (".inc", ".fi", ".fypp"): 34 return "!", suffix in FIXED_FORM 35 if suffix in HASH_COMMENTS or path.name.lower() in HASH_NAMES: 36 return "#", False 37 if suffix in SLASH_COMMENTS: 38 return "//", False 39 return "", False
(the line comment for this file, whether it's fixed-form Fortran): "!" for Fortran, "#" for TOML, Python, shell…, "//" for C and the like, "" for a file without a line comment.
50def toggle_comment(lines: list[str], prefix: str, fixed: bool = False) -> list[str]: 51 """The lines commented out, or, if every one with code already is, back 52 as they were. A comment goes at the lines' least indentation ("! x = 1"), 53 or, in fixed form, in the first column ("!" there is a comment; at the 54 sixth it would make the line a continuation). Blank lines stay blank.""" 55 code = [line for line in lines if line.strip()] 56 if not prefix or not code: 57 return list(lines) 58 if all(_commented(line, prefix, fixed) for line in code): 59 out = [] 60 for line in lines: 61 if not line.strip() or not _commented(line, prefix, fixed): 62 out.append(line) 63 elif fixed: 64 out.append(line[len(prefix):]) 65 else: 66 start = len(line) - len(line.lstrip()) 67 rest = line[start + len(prefix):] 68 out.append(line[:start] + (rest.removeprefix(" "))) 69 return out 70 if fixed: 71 return [prefix + line if line.strip() else line for line in lines] 72 indent = min(len(line) - len(line.lstrip()) for line in code) 73 return [line[:indent] + prefix + " " + line[indent:] if line.strip() else line for line in lines]
The lines commented out, or, if every one with code already is, back as they were. A comment goes at the lines' least indentation ("! x = 1"), or, in fixed form, in the first column ("!" there is a comment; at the sixth it would make the line a continuation). Blank lines stay blank.
76def sort_lines(lines: list[str], descending: bool = False) -> list[str]: 77 """The lines in order, ignoring case (and, where only case differs, as given).""" 78 return sorted(lines, key=lambda line: (line.casefold(), line), reverse=descending)
The lines in order, ignoring case (and, where only case differs, as given).
84def change_case(text: str, case: str) -> str: 85 """UPPER CASE, lower case, or Title Case (each word's first letter, the rest lower).""" 86 if case == UPPER: 87 return text.upper() 88 if case == LOWER: 89 return text.lower() 90 return re.sub(r"[^\W_]+", lambda m: m.group()[:1].upper() + m.group()[1:].lower(), text)
UPPER CASE, lower case, or Title Case (each word's first letter, the rest lower).
98class LineEditsMixin: 99 """Toggle Line Comment, Move Line Up / Down, Sort Lines and Change Case 100 (a part of `vibefpmgui.code_editor.CodeEditor`).""" 101 102 comment_prefix = "!" # this file's line comment ("" for none: see comment_style) 103 fixed_form = False # fixed-form Fortran (its comments go in the first column) 104 105 def _edit_cursor(self) -> QTextCursor: 106 """A cursor for an edit, where the user's is: an Undo puts the cursor 107 back where its edit began (one at the document's start sent it to 108 the top of the file).""" 109 return QTextCursor(self.textCursor()) 110 111 def _line_span(self, cursor: QTextCursor) -> tuple[int, int]: 112 """The lines (0-based, first and last) a cursor's selection is on: not 113 the last if the selection only reaches its start.""" 114 doc = self.document() 115 start, end = sorted((cursor.anchor(), cursor.position())) 116 first = doc.findBlock(start).blockNumber() 117 last_block = doc.findBlock(end) 118 last = last_block.blockNumber() 119 if last > first and end == last_block.position(): 120 last -= 1 121 return first, last 122 123 def _lines(self, first: int, last: int) -> list[str]: 124 doc = self.document() 125 return [doc.findBlockByNumber(n).text() for n in range(first, last + 1)] 126 127 def _replace_lines(self, cursor: QTextCursor, first: int, last: int, lines: list[str]): 128 """Lines ``first`` to ``last`` become ``lines`` (as many), with ``cursor``.""" 129 doc = self.document() 130 end = doc.findBlockByNumber(last) 131 cursor.setPosition(doc.findBlockByNumber(first).position()) 132 cursor.setPosition(end.position() + end.length() - 1, QTextCursor.KeepAnchor) 133 cursor.insertText("\n".join(lines)) 134 135 def toggle_comment(self): 136 """Edit → Toggle Line Comment: the lines of the selection (and of the 137 other cursors'), commented out or back.""" 138 if not self.comment_prefix: 139 return 140 spans = sorted({self._line_span(c) for c in [self.textCursor(), *self.extra_cursors]}) 141 lines = sorted({n for first, last in spans for n in range(first, last + 1)}) 142 if not lines: 143 return 144 all_lines = self._lines(lines[0], lines[-1]) 145 chosen = [all_lines[n - lines[0]] for n in lines] 146 changed = toggle_comment(chosen, self.comment_prefix, self.fixed_form) 147 if changed == chosen: 148 return 149 new = list(all_lines) 150 for n, line in zip(lines, changed, strict=True): 151 new[n - lines[0]] = line 152 main = self.textCursor() 153 line, column = main.blockNumber(), main.positionInBlock() 154 anchor_block = self.document().findBlock(main.anchor()) 155 anchor_line, anchor_column = anchor_block.blockNumber(), main.anchor() - anchor_block.position() 156 cursor = self._edit_cursor() 157 cursor.beginEditBlock() 158 self._replace_lines(cursor, lines[0], lines[-1], new) 159 cursor.endEditBlock() 160 # The selection stays on the same lines (its columns moved by what was put in or taken out). 161 self._select_columns(anchor_line, anchor_column, line, column, all_lines, new, lines[0]) 162 163 def _select_columns(self, anchor_line, anchor_column, line, column, old, new, first): 164 def moved(n: int, col: int) -> int: 165 if not first <= n < first + len(old): 166 return col 167 before, after = old[n - first], new[n - first] 168 shift = _qt_length(after) - _qt_length(before) 169 indent = _qt_length(before) - _qt_length(before.lstrip()) 170 return max(0, col + shift) if col >= indent or self.fixed_form else col 171 172 doc = self.document() 173 cursor = QTextCursor(doc) 174 block = doc.findBlockByNumber(anchor_line) 175 cursor.setPosition(block.position() + min(moved(anchor_line, anchor_column), block.length() - 1)) 176 block = doc.findBlockByNumber(line) 177 cursor.setPosition(block.position() + min(moved(line, column), block.length() - 1), QTextCursor.KeepAnchor) 178 self.setTextCursor(cursor) 179 180 def move_lines(self, down: bool): 181 """Edit → Lines → Move Line Up / Down: the selection's lines swap 182 places with the line above or below, the selection going with them.""" 183 doc = self.document() 184 main = self.textCursor() 185 first, last = self._line_span(main) 186 if (not down and first == 0) or (down and last >= doc.blockCount() - 1): 187 return 188 lines = self._lines(first, last) 189 if down: 190 neighbour = doc.findBlockByNumber(last + 1) 191 new, span, shift = [neighbour.text(), *lines], (first, last + 1), neighbour.length() 192 else: 193 neighbour = doc.findBlockByNumber(first - 1) 194 new, span, shift = [*lines, neighbour.text()], (first - 1, last), -neighbour.length() 195 anchor, position = main.anchor(), main.position() 196 cursor = self._edit_cursor() 197 cursor.beginEditBlock() 198 self._replace_lines(cursor, *span, new) 199 cursor.endEditBlock() 200 moved = QTextCursor(doc) 201 moved.setPosition(anchor + shift) 202 moved.setPosition(position + shift, QTextCursor.KeepAnchor) 203 self.setTextCursor(moved) 204 205 def sort_selected_lines(self, descending: bool = False): 206 """Edit → Lines → Sort Lines: the selection's lines (two or more) in order.""" 207 first, last = self._line_span(self.textCursor()) 208 if last <= first: 209 return 210 lines = self._lines(first, last) 211 ordered = sort_lines(lines, descending) 212 if ordered == lines: 213 return 214 cursor = self._edit_cursor() 215 cursor.beginEditBlock() 216 self._replace_lines(cursor, first, last, ordered) 217 cursor.endEditBlock() 218 doc = self.document() 219 end = doc.findBlockByNumber(last) 220 selection = QTextCursor(doc) 221 selection.setPosition(doc.findBlockByNumber(first).position()) 222 selection.setPosition(end.position() + end.length() - 1, QTextCursor.KeepAnchor) 223 self.setTextCursor(selection) 224 225 def change_selection_case(self, case: str): 226 """Edit → Change Case: each selection (the main one and the other 227 cursors'), or the word at a cursor without one.""" 228 cursors = [self.textCursor(), *self.extra_cursors] 229 edit = self._edit_cursor() 230 edit.beginEditBlock() 231 for c in cursors: 232 target = QTextCursor(c) 233 if not target.hasSelection(): 234 target.select(QTextCursor.WordUnderCursor) 235 text = target.selectedText() 236 new = change_case(text, case) 237 if text and new != text: 238 selected = c.hasSelection() 239 start = target.selectionStart() 240 target.insertText(new) 241 if selected: # (still selected; its length may change, as "ß" → "SS") 242 c.setPosition(start) 243 c.setPosition(start + _qt_length(new), QTextCursor.KeepAnchor) 244 edit.endEditBlock() 245 self.setTextCursor(cursors[0])
Toggle Line Comment, Move Line Up / Down, Sort Lines and Change Case
(a part of vibefpmgui.code_editor.CodeEditor).
135 def toggle_comment(self): 136 """Edit → Toggle Line Comment: the lines of the selection (and of the 137 other cursors'), commented out or back.""" 138 if not self.comment_prefix: 139 return 140 spans = sorted({self._line_span(c) for c in [self.textCursor(), *self.extra_cursors]}) 141 lines = sorted({n for first, last in spans for n in range(first, last + 1)}) 142 if not lines: 143 return 144 all_lines = self._lines(lines[0], lines[-1]) 145 chosen = [all_lines[n - lines[0]] for n in lines] 146 changed = toggle_comment(chosen, self.comment_prefix, self.fixed_form) 147 if changed == chosen: 148 return 149 new = list(all_lines) 150 for n, line in zip(lines, changed, strict=True): 151 new[n - lines[0]] = line 152 main = self.textCursor() 153 line, column = main.blockNumber(), main.positionInBlock() 154 anchor_block = self.document().findBlock(main.anchor()) 155 anchor_line, anchor_column = anchor_block.blockNumber(), main.anchor() - anchor_block.position() 156 cursor = self._edit_cursor() 157 cursor.beginEditBlock() 158 self._replace_lines(cursor, lines[0], lines[-1], new) 159 cursor.endEditBlock() 160 # The selection stays on the same lines (its columns moved by what was put in or taken out). 161 self._select_columns(anchor_line, anchor_column, line, column, all_lines, new, lines[0])
Edit → Toggle Line Comment: the lines of the selection (and of the other cursors'), commented out or back.
180 def move_lines(self, down: bool): 181 """Edit → Lines → Move Line Up / Down: the selection's lines swap 182 places with the line above or below, the selection going with them.""" 183 doc = self.document() 184 main = self.textCursor() 185 first, last = self._line_span(main) 186 if (not down and first == 0) or (down and last >= doc.blockCount() - 1): 187 return 188 lines = self._lines(first, last) 189 if down: 190 neighbour = doc.findBlockByNumber(last + 1) 191 new, span, shift = [neighbour.text(), *lines], (first, last + 1), neighbour.length() 192 else: 193 neighbour = doc.findBlockByNumber(first - 1) 194 new, span, shift = [*lines, neighbour.text()], (first - 1, last), -neighbour.length() 195 anchor, position = main.anchor(), main.position() 196 cursor = self._edit_cursor() 197 cursor.beginEditBlock() 198 self._replace_lines(cursor, *span, new) 199 cursor.endEditBlock() 200 moved = QTextCursor(doc) 201 moved.setPosition(anchor + shift) 202 moved.setPosition(position + shift, QTextCursor.KeepAnchor) 203 self.setTextCursor(moved)
Edit → Lines → Move Line Up / Down: the selection's lines swap places with the line above or below, the selection going with them.
205 def sort_selected_lines(self, descending: bool = False): 206 """Edit → Lines → Sort Lines: the selection's lines (two or more) in order.""" 207 first, last = self._line_span(self.textCursor()) 208 if last <= first: 209 return 210 lines = self._lines(first, last) 211 ordered = sort_lines(lines, descending) 212 if ordered == lines: 213 return 214 cursor = self._edit_cursor() 215 cursor.beginEditBlock() 216 self._replace_lines(cursor, first, last, ordered) 217 cursor.endEditBlock() 218 doc = self.document() 219 end = doc.findBlockByNumber(last) 220 selection = QTextCursor(doc) 221 selection.setPosition(doc.findBlockByNumber(first).position()) 222 selection.setPosition(end.position() + end.length() - 1, QTextCursor.KeepAnchor) 223 self.setTextCursor(selection)
Edit → Lines → Sort Lines: the selection's lines (two or more) in order.
225 def change_selection_case(self, case: str): 226 """Edit → Change Case: each selection (the main one and the other 227 cursors'), or the word at a cursor without one.""" 228 cursors = [self.textCursor(), *self.extra_cursors] 229 edit = self._edit_cursor() 230 edit.beginEditBlock() 231 for c in cursors: 232 target = QTextCursor(c) 233 if not target.hasSelection(): 234 target.select(QTextCursor.WordUnderCursor) 235 text = target.selectedText() 236 new = change_case(text, case) 237 if text and new != text: 238 selected = c.hasSelection() 239 start = target.selectionStart() 240 target.insertText(new) 241 if selected: # (still selected; its length may change, as "ß" → "SS") 242 c.setPosition(start) 243 c.setPosition(start + _qt_length(new), QTextCursor.KeepAnchor) 244 edit.endEditBlock() 245 self.setTextCursor(cursors[0])
Edit → Change Case: each selection (the main one and the other cursors'), or the word at a cursor without one.